chore: import upstream snapshot with attribution
docmd CI verification / verify (push) Failing after 0s
docmd CI verification / verify (push) Failing after 0s
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
@@ -0,0 +1,48 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at hello@mgks.dev.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.1, available at
|
||||
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
@@ -0,0 +1,85 @@
|
||||
# Contributing to `docmd`
|
||||
|
||||
|
||||
Thank you for your interest in contributing to `docmd`! We appreciate all contributions, from bug fixes and documentation improvements to new features and design suggestions.
|
||||
|
||||
## Development Environment
|
||||
|
||||
`docmd` is a monorepo managed with [pnpm](https://pnpm.io/).
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js**: v22.x or later (LTS recommended)
|
||||
- **pnpm**: v10.x or later
|
||||
|
||||
### Project Setup
|
||||
|
||||
Clone the repository and set up the development environment:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/docmd-io/docmd.git
|
||||
cd docmd
|
||||
pnpm prep
|
||||
```
|
||||
|
||||
To also link the local `docmd` command globally for testing in other projects:
|
||||
|
||||
```bash
|
||||
pnpm prep --link
|
||||
```
|
||||
|
||||
### Local Development
|
||||
|
||||
Run the documentation site while watching for changes in the core engine:
|
||||
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
To watch internal source files (engine, templates, and plugins), set the `DOCMD_DEV` environment variable:
|
||||
|
||||
```bash
|
||||
DOCMD_DEV=true pnpm run dev
|
||||
```
|
||||
|
||||
## Quality Standards
|
||||
|
||||
Before submitting a Pull Request, please ensure your changes pass the verification suite:
|
||||
|
||||
```bash
|
||||
pnpm verify
|
||||
```
|
||||
|
||||
### Commit Guidelines
|
||||
|
||||
We use [Conventional Commits](https://www.conventionalcommits.org/). Please prefix your commit messages with:
|
||||
- `feat:` (New features)
|
||||
- `fix:` (Bug fixes)
|
||||
- `docs:` (Documentation changes)
|
||||
- `refactor:` (Code changes that neither fix bugs nor add features)
|
||||
|
||||
### Source Headers
|
||||
|
||||
All new files within the `packages/` directory MUST include the standard project copyright header to maintain consistency and compliance.
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the minimalist, zero-config documentation generator.
|
||||
*
|
||||
* @package @docmd/core (and ecosystem)
|
||||
* @website https://docmd.io
|
||||
* @repository https://github.com/docmd-io/docmd
|
||||
* @license MIT
|
||||
* @copyright Copyright (c) 2025-present docmd.io
|
||||
*
|
||||
* [docmd-source] - Please do not remove this header.
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
```
|
||||
|
||||
## GitHub Workflow
|
||||
|
||||
1. **Fork and Branch**: Create a feature branch from the latest `main`.
|
||||
2. **Verify**: Ensure `pnpm verify` returns `🛡️ docmd is ready for production!`.
|
||||
3. **Pull Request**: Open a PR with a clear description of the problem solved or the feature added.
|
||||
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: mgks # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
@@ -0,0 +1,58 @@
|
||||
name: 🐞 Bug Report
|
||||
description: Create a report to help us improve docmd
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report!
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: docmd version
|
||||
description: What version of docmd are you using? (e.g. 0.2.6)
|
||||
placeholder: 0.2.6
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: node-version
|
||||
attributes:
|
||||
label: Node.js version
|
||||
placeholder: v20.x
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: OS/Browser
|
||||
options:
|
||||
- macOS / Safari
|
||||
- Windows / Edge
|
||||
- Linux / Chrome
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: A clear and concise description of what the bug is.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Please provide the steps to reproduce the behavior.
|
||||
placeholder: |
|
||||
1. Run 'docmd init'
|
||||
2. Add configuration...
|
||||
3. Run 'docmd build'
|
||||
4. See error...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
@@ -0,0 +1,27 @@
|
||||
name: 💡 Feature Request
|
||||
description: Suggest an idea for this project
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for suggesting a new feature!
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Is your feature request related to a problem?
|
||||
description: A clear and concise description of what the problem is.
|
||||
placeholder: I'm always frustrated when...
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Describe the solution you'd like
|
||||
description: A clear and concise description of what you want to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Describe alternatives you've considered
|
||||
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||
@@ -0,0 +1,16 @@
|
||||
## Description
|
||||
<!-- Please include a summary of the change and which issue is fixed. -->
|
||||
|
||||
Fixes # (issue)
|
||||
|
||||
## Type of change
|
||||
- [ ] 🐛 Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] ✨ New feature (non-breaking change which adds functionality)
|
||||
- [ ] 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] 📝 Documentation update
|
||||
|
||||
## Checklist:
|
||||
- [ ] I have performed a self-review of my own code
|
||||
- [ ] I have commented my code, particularly in hard-to-understand areas
|
||||
- [ ] I have made corresponding changes to the documentation
|
||||
- [ ] My changes generate no new warnings
|
||||
@@ -0,0 +1,18 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 0.2.x | :white_check_mark: |
|
||||
| 0.1.x | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Use this section to tell people how to report a vulnerability.
|
||||
|
||||
Please do not report security vulnerabilities through public GitHub issues.
|
||||
|
||||
If you have discovered a security vulnerability in `docmd`, please email hello@mgks.dev.
|
||||
|
||||
We will do our best to respond within 48 hours.
|
||||
@@ -0,0 +1,44 @@
|
||||
version: 2
|
||||
updates:
|
||||
# -------------------------------------------------------
|
||||
# 1. GitHub Actions (Universal for all your projects)
|
||||
# Keeps your workflow files (checkout, setup-node) updated
|
||||
# -------------------------------------------------------
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 2. NPM (Specific to tree-fs / Node projects)
|
||||
# -------------------------------------------------------
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
# Ignore major updates automatically to prevent breaking changes
|
||||
# Remove this 'ignore' block if you want to see v1 -> v2 updates
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
groups:
|
||||
# Group all minor/patch updates into one PR
|
||||
npm-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 3. (Optional) Uncomment for Python projects
|
||||
# -------------------------------------------------------
|
||||
# - package-ecosystem: "pip"
|
||||
# directory: "/"
|
||||
# schedule:
|
||||
# interval: "weekly"
|
||||
# groups:
|
||||
# python-deps:
|
||||
# patterns:
|
||||
# - "*"
|
||||
@@ -0,0 +1,40 @@
|
||||
name: docmd CI verification
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build packages
|
||||
run: pnpm -r run build
|
||||
|
||||
- name: Run verification
|
||||
run: pnpm verify --skip-setup
|
||||
|
||||
- name: Run Linting
|
||||
run: pnpm lint
|
||||
@@ -0,0 +1,219 @@
|
||||
# --------------------------------------------------------------------
|
||||
# docmd : the minimalist, zero-config documentation generator.
|
||||
#
|
||||
# @package @docmd/core (and ecosystem)
|
||||
# @website https://docmd.io
|
||||
# @repository https://github.com/docmd-io/docmd
|
||||
# @license MIT
|
||||
# @copyright Copyright (c) 2025-present docmd.io
|
||||
#
|
||||
# [docmd-source] - Please do not remove this header.
|
||||
# --------------------------------------------------------------------
|
||||
#
|
||||
# GitHub Actions workflow for building and publishing Docker images
|
||||
# Uses OIDC (OpenID Connect) for passwordless authentication to GHCR
|
||||
#
|
||||
# Triggers:
|
||||
# - Release published on GitHub
|
||||
# - Manual workflow dispatch
|
||||
# - Push to main (for testing)
|
||||
#
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
push_image:
|
||||
description: 'Push image to registry'
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
version:
|
||||
description: 'Version tag (e.g., X.Y.Z)'
|
||||
required: false
|
||||
type: string
|
||||
tag_latest:
|
||||
description: 'Also tag this build as :latest (use only for stable releases)'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write # Required for OIDC
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build and push Docker image
|
||||
# ---------------------------------------------------------------------------
|
||||
build:
|
||||
name: Build Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.meta.outputs.version }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Set up Docker Buildx for multi-platform builds
|
||||
# -------------------------------------------------------------------------
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Login to GitHub Container Registry using OIDC
|
||||
# The id-token: write permission enables passwordless authentication
|
||||
# -------------------------------------------------------------------------
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Extract metadata for Docker
|
||||
# -------------------------------------------------------------------------
|
||||
- name: Extract Docker metadata
|
||||
# Skip on plain branch pushes (the "for testing" trigger) — the
|
||||
# build below is local-only (push: is gated to release / manual),
|
||||
# so no tags are needed and the action's tag rules don't apply.
|
||||
if: github.event_name != 'push' || startsWith(github.ref, 'refs/tags/')
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ github.repository }}
|
||||
# Two and only two tags are ever produced for any single image:
|
||||
# - The version (e.g. X.Y.Z, v-prefix stripped)
|
||||
# - The floating `:latest` alias
|
||||
# `:latest` is only ever applied to released versions. A branch
|
||||
# build or unreleased commit can never be pulled as `:latest`.
|
||||
# flavor=latest=false disables the action's auto-latest so the
|
||||
# only source of `:latest` is the explicit rule below.
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
# Version: ref on tag push and release events (strips 'v' prefix)
|
||||
type=ref,event=tag
|
||||
# :latest — only on release events
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
|
||||
# Manual dispatch: use provided version + optional :latest
|
||||
type=raw,value=${{ inputs.version }},enable=${{ github.event_name == 'workflow_dispatch' && inputs.version != '' }}
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'workflow_dispatch' && inputs.tag_latest }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=docmd
|
||||
org.opencontainers.image.description=The minimalist, zero-config documentation generator
|
||||
org.opencontainers.image.vendor=docmd.io
|
||||
org.opencontainers.image.licenses=MIT
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Build and push Docker image
|
||||
# Multi-platform: linux/amd64, linux/arm64
|
||||
# -------------------------------------------------------------------------
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.push_image) }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Output build info
|
||||
# -------------------------------------------------------------------------
|
||||
- name: Output image info
|
||||
run: |
|
||||
echo "## Docker Image Published! :whale:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Image Details" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Property | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Registry | \`${{ env.REGISTRY }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Image | \`${{ env.REGISTRY }}/${{ github.repository }}:${{ steps.meta.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | \`${{ steps.meta.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Platforms | \`linux/amd64, linux/arm64\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Usage" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```bash' >> $GITHUB_STEP_SUMMARY
|
||||
echo "# Pull the image" >> $GITHUB_STEP_SUMMARY
|
||||
echo "docker pull ${{ env.REGISTRY }}/${{ github.repository }}:${{ steps.meta.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "# Run development server" >> $GITHUB_STEP_SUMMARY
|
||||
echo "docker run -v \$(pwd)/docs:/docs -p 3000:3000 ${{ env.REGISTRY }}/${{ github.repository }}:${{ steps.meta.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "# Build static site" >> $GITHUB_STEP_SUMMARY
|
||||
echo "docker run -v \$(pwd)/docs:/docs -v \$(pwd)/site:/site ${{ env.REGISTRY }}/${{ github.repository }}:${{ steps.meta.outputs.version }} build" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test the Docker image (optional but recommended)
|
||||
# ---------------------------------------------------------------------------
|
||||
test:
|
||||
name: Test Docker Image
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.push_image)
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Pull image
|
||||
run: docker pull ${{ env.REGISTRY }}/${{ github.repository }}:${{ needs.build.outputs.version }}
|
||||
|
||||
- name: Test build command
|
||||
run: |
|
||||
# Create test directory with proper permissions for container user (UID 1001)
|
||||
mkdir -p /tmp/docmd-test
|
||||
sudo chown -R 1001:1001 /tmp/docmd-test
|
||||
|
||||
# Simulate exactly what a new user does: init a project then build it
|
||||
docker run --rm \
|
||||
-v /tmp/docmd-test:/workspace \
|
||||
--workdir /workspace \
|
||||
--entrypoint sh \
|
||||
${{ env.REGISTRY }}/${{ github.repository }}:${{ needs.build.outputs.version }} \
|
||||
-c "cp -r /template/. /workspace/ && docmd build"
|
||||
|
||||
- name: Verify build output
|
||||
run: |
|
||||
if [ -f "/tmp/docmd-test/site/index.html" ]; then
|
||||
echo "✅ Build successful - site/index.html exists"
|
||||
echo "📊 Site size: $(du -sh /tmp/docmd-test/site | cut -f1)"
|
||||
else
|
||||
echo "❌ Build failed - site/index.html not found"
|
||||
ls -la /tmp/docmd-test/
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,180 @@
|
||||
name: Release docmd to NPM
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
packages: write
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Job 1: Build Rust native binaries (matrix, one runner per platform)
|
||||
#
|
||||
# Each runner compiles the napi-rs addon for its target, then uploads the
|
||||
# resulting .node file as a workflow artifact. The publish job downloads
|
||||
# all artifacts and places them in rust-binaries/bin/ before publishing.
|
||||
# ---------------------------------------------------------------------------
|
||||
jobs:
|
||||
build-native:
|
||||
name: Build native (${{ matrix.target }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest # Apple Silicon runner (M-series)
|
||||
target: aarch64-apple-darwin
|
||||
binary: docmd-engine-darwin-arm64.node
|
||||
lib: libdocmd_engine.dylib
|
||||
|
||||
- os: macos-latest # Cross-compile Intel binary on ARM64 runner (Faster)
|
||||
target: x86_64-apple-darwin
|
||||
binary: docmd-engine-darwin-x64.node
|
||||
lib: libdocmd_engine.dylib
|
||||
|
||||
- os: ubuntu-latest # Cross-compile Linux x64 binary on ARM64 runner (Faster)
|
||||
target: x86_64-unknown-linux-gnu
|
||||
binary: docmd-engine-linux-x64.node
|
||||
lib: libdocmd_engine.so
|
||||
|
||||
- os: ubuntu-24.04-arm # GitHub-hosted ARM64 Linux runner
|
||||
target: aarch64-unknown-linux-gnu
|
||||
binary: docmd-engine-linux-arm64.node
|
||||
lib: libdocmd_engine.so
|
||||
|
||||
- os: windows-latest # Cross-compile Windows x64 binary on ARM64 runner (Faster)
|
||||
target: x86_64-pc-windows-msvc
|
||||
binary: docmd-engine-win32-x64.node
|
||||
lib: docmd_engine.dll
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install Rust toolchain
|
||||
run: |
|
||||
rustup toolchain install stable --profile minimal --target ${{ matrix.target }}
|
||||
rustup default stable
|
||||
|
||||
- name: Cache Rust build
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: packages/engines/rust-binaries/native
|
||||
|
||||
- name: Debug cargo environment
|
||||
run: |
|
||||
echo "PATH: $PATH"
|
||||
which cargo
|
||||
cargo --version
|
||||
rustup show
|
||||
|
||||
- name: Build native addon
|
||||
working-directory: packages/engines/rust-binaries/native
|
||||
run: cargo build --release --target ${{ matrix.target }}
|
||||
|
||||
# Copy the built library to bin/ with the correct name
|
||||
- name: Collect binary (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
run: |
|
||||
mkdir -p packages/engines/rust-binaries/bin
|
||||
cp packages/engines/rust-binaries/native/target/${{ matrix.target }}/release/${{ matrix.lib }} \
|
||||
packages/engines/rust-binaries/bin/${{ matrix.binary }}
|
||||
|
||||
- name: Collect binary (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path packages/engines/rust-binaries/bin
|
||||
Copy-Item "packages/engines/rust-binaries/native/target/${{ matrix.target }}/release/${{ matrix.lib }}" `
|
||||
"packages/engines/rust-binaries/bin/${{ matrix.binary }}"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: native-${{ matrix.target }}
|
||||
path: packages/engines/rust-binaries/bin/${{ matrix.binary }}
|
||||
if-no-files-found: error
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Job 2: Publish all packages to npm
|
||||
#
|
||||
# Runs after all matrix builds succeed. Downloads every .node artifact into
|
||||
# rust-binaries/bin/ so the package contains the binaries when npm publish runs.
|
||||
# ---------------------------------------------------------------------------
|
||||
publish:
|
||||
name: Publish to npm
|
||||
needs: build-native
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Upgrade npm (required for OIDC provenance)
|
||||
run: npm install -g npm@11.12.1
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
# Download all .node binaries built by the matrix into rust-binaries/bin/
|
||||
- name: Download native binaries
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: native-*
|
||||
path: packages/engines/rust-binaries/bin
|
||||
merge-multiple: true
|
||||
|
||||
- name: Verify binaries
|
||||
run: ls -lh packages/engines/rust-binaries/bin/
|
||||
|
||||
# Build TypeScript for all packages
|
||||
- name: Build packages
|
||||
run: pnpm -r run build
|
||||
|
||||
- name: Run verification
|
||||
run: pnpm verify --skip-setup
|
||||
|
||||
- name: Copy LICENSE into each package
|
||||
run: |
|
||||
find packages -mindepth 1 -maxdepth 4 -name "package.json" \
|
||||
-not -path "*/node_modules/*" \
|
||||
-exec dirname {} \; | xargs -I % cp LICENSE %
|
||||
|
||||
- name: Resolve workspace dependencies
|
||||
run: node tools/resolve-ws-deps.js
|
||||
|
||||
- name: Publish to npm
|
||||
run: |
|
||||
for dir in packages/* packages/legacy/* packages/plugins/* packages/engines/* packages/templates/*; do
|
||||
if [ -f "$dir/package.json" ]; then
|
||||
# Skip private packages
|
||||
if grep -q '"private": true' "$dir/package.json"; then
|
||||
echo "::notice::Skipping $dir: private package"
|
||||
continue
|
||||
fi
|
||||
echo "Publishing $dir..."
|
||||
exit_code=0
|
||||
publish_output=$(npm publish "./$dir" --access public --provenance 2>&1) || exit_code=$?
|
||||
if [ $exit_code -ne 0 ]; then
|
||||
if echo "$publish_output" | grep -q 'previously published versions\|EPRIVATE'; then
|
||||
echo "::notice::Skipping $dir: Already published or private."
|
||||
else
|
||||
echo "::error::Failed to publish $dir"
|
||||
echo "$publish_output"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Build Outputs
|
||||
dist/
|
||||
public/
|
||||
site/
|
||||
out/
|
||||
*.tsbuildinfo
|
||||
|
||||
# OS Specific
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Project Specific
|
||||
genctx.config.json
|
||||
genctx.context.md
|
||||
_backup/
|
||||
_documentation/
|
||||
temp-*/
|
||||
.cache/
|
||||
.vscode/
|
||||
|
||||
# Environment
|
||||
.env*
|
||||
!.env.example
|
||||
temp-live-test.mjs
|
||||
|
||||
# Rust Engine
|
||||
packages/engines/rust/bin/*.node
|
||||
packages/engines/rust-binaries/native/target/
|
||||
|
||||
# Docmd Cache
|
||||
.docmd/
|
||||
.docmd-search/
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025-present docmd.io
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
<div align="right">
|
||||
<sup>
|
||||
<a href="./README.md">EN</a> | <a href="./README.es.md">ES</a> | <b>DE</b> | <a href="./README.ja.md">日本語</a> | <a href="./README.fr.md">FR</a> | <a href="./README.zh.md">中文</a>
|
||||
</sup>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://docmd.io">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/docmd-io/docmd/blob/main/packages/ui/assets/images/docmd-logo-dark.png?raw=true" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://github.com/docmd-io/docmd/blob/main/packages/ui/assets/images/docmd-logo-dark.png?raw=true" />
|
||||
<img src="https://github.com/docmd-io/docmd/blob/main/packages/ui/assets/images/docmd-logo-dark.png?raw=true" alt="docmd" width="210" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
<br/>
|
||||
|
||||
<p><b>Produktionsreife Dokumentation aus Markdown, in Sekunden.</b><br/>Zero Config. AI-nativ. Für Entwickler gebaut.</p>
|
||||
|
||||
<p>
|
||||
<a href="https://www.npmjs.com/package/@docmd/core"><img src="https://img.shields.io/npm/v/@docmd/core.svg?style=flat-square&color=CB3837" alt="npm version"></a>
|
||||
<a href="https://www.npmjs.com/package/@docmd/core?activeTab=versions"><img src="https://img.shields.io/npm/dm/@docmd/core.svg?style=flat-square&color=38bd24" alt="monatliche Downloads"></a>
|
||||
<a href="https://github.com/docmd-io/docmd"><img src="https://img.shields.io/github/stars/docmd-io/docmd?style=flat-square&logo=github" alt="GitHub-Sterne"></a>
|
||||
<a href="https://github.com/docmd-io/docmd/blob/main/LICENSE"><img src="https://img.shields.io/github/license/docmd-io/docmd.svg?style=flat-square&color=A31F34" alt="Lizenz"></a>
|
||||
</p>
|
||||
|
||||
<h4>
|
||||
<a href="https://docmd.io">Website</a> ·
|
||||
<a href="https://docs.docmd.io">Dokumentation</a> ·
|
||||
<a href="https://live.docmd.io">Live-Editor</a> ·
|
||||
<a href="https://github.com/docmd-io/docmd-skills">Agent Skills</a> ·
|
||||
<a href="https://github.com/docmd-io/docmd/issues">Bug melden</a>
|
||||
</h4>
|
||||
|
||||
<br/>
|
||||
|
||||
<a href="https://docs.docmd.io">
|
||||
<img width="820" alt="docmd Standard-Theme — Vorschau Light- und Dark-Mode" src="https://raw.githubusercontent.com/docmd-io/docmd/refs/heads/main/assets/docmd-cover.webp" />
|
||||
</a>
|
||||
|
||||
<br/><br/>
|
||||
|
||||
</div>
|
||||
|
||||
## Schnellstart
|
||||
|
||||
Starten Sie docmd in jedem Ordner mit Markdown-Dateien — keine Installation nötig:
|
||||
|
||||
```bash
|
||||
npx @docmd/core dev
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>Öffnet unter <code>http://localhost:3000</code></b></summary><br>
|
||||
|
||||
```bash
|
||||
_ _
|
||||
_| |___ ___ _____ _| |
|
||||
| . | . | _| | . |
|
||||
|___|___|___|_|_|_|___|
|
||||
|
||||
v1.x.x
|
||||
|
||||
┌─ Build
|
||||
│ Engine JS
|
||||
│ Source docs/
|
||||
│ Output site/
|
||||
│ Versions 2 (06, 05)
|
||||
│ Locales 7 (en, hi, zh, es, de, ja, fr)
|
||||
└──────────────────────────────────────────────────────────
|
||||
┌─ Data Indexing
|
||||
│ [ DONE ] Syncing git metadata
|
||||
│ [ DONE ] Building semantic search index (multi-version)
|
||||
└──────────────────────────────────────────────────────────
|
||||
┌─ Publishing
|
||||
│ [ DONE ] Generated robots.txt
|
||||
│ [ DONE ] Generated .nojekyll (disables Jekyll on GitHub Pages)
|
||||
│ [ DONE ] Generated sitemap
|
||||
│ [ DONE ] Generating LLMs context files
|
||||
└──────────────────────────────────────────────────────────
|
||||
|
||||
⬢ Initial build completed in 1.2s.
|
||||
|
||||
┌─ Watching
|
||||
│ Source ./docs
|
||||
│ Config ./docmd.config.json
|
||||
│ Assets ./assets
|
||||
└──────────────────────────────────────────────────────────
|
||||
┌─ Development Server Running
|
||||
│ Local Access http://127.0.0.1:3000
|
||||
│ Network Access http://192.168.1.6:3000
|
||||
│ Serving from ./site
|
||||
└──────────────────────────────────────────────────────────
|
||||
```
|
||||
</details>
|
||||
|
||||
<p align="center">
|
||||
<img alt="docmd Dev-Server Vorschau" width="820" src="https://docmd.io/assets/images/dev-preview.gif">
|
||||
</p>
|
||||
|
||||
Die Navigation wird aus Ihrer Verzeichnisstruktur generiert. Keine Config-Datei, kein Frontmatter nötig, kein Framework zu lernen.
|
||||
|
||||
**Wenn Sie bereit zum Veröffentlichen sind:**
|
||||
|
||||
```bash
|
||||
npx @docmd/core build
|
||||
```
|
||||
|
||||
Dies erzeugt eine hochoptimierte statische Site (SPA), bereit für das Deployment zu Vercel, Cloudflare Pages, Netlify, GitHub Pages oder jedem beliebigen Static Host.
|
||||
|
||||
**Anforderungen:** Node.js 18+
|
||||
|
||||
<details>
|
||||
<summary><b>Oder global installieren / per Docker</b></summary><br/>
|
||||
|
||||
```bash
|
||||
# Global via npm installieren
|
||||
npm install -g @docmd/core
|
||||
|
||||
# Oder via pnpm
|
||||
pnpm add -g @docmd/core
|
||||
|
||||
# Ausführen
|
||||
docmd dev # Dev-Server starten
|
||||
docmd build # Für Deployment bauen
|
||||
```
|
||||
|
||||
Oder per Docker:
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 ghcr.io/docmd-io/docmd:0.8.7
|
||||
```
|
||||
|
||||
> Versionieren Sie für reproduzierbare Builds.
|
||||
|
||||
</details>
|
||||
|
||||
## Warum docmd?
|
||||
|
||||
| Feature | docmd | Docusaurus | MkDocs | VitePress | Mintlify |
|
||||
| :--- | :---: | :---: | :---: | :---: | :---: |
|
||||
| **Config erforderlich** | **Keine** | `docusaurus.config.js` | `mkdocs.yml` | `config.mts` | `docs.json` |
|
||||
| **JS-Payload** | **~18 kb** | ~250 kb | ~40 kb | ~50 kb | ~120 kb |
|
||||
| **Navigation** | **Sofortige SPA** | React SPA | Vollständiger Reload | Vue SPA | Gehostete SPA |
|
||||
| **Versionierung** | **Nativ** | Nativ (komplex) | mike-Plugin | Manuell | Nativ |
|
||||
| **i18n** | **Nativ** | Nativ (komplex) | Plugin-basiert | Nativ | Nativ |
|
||||
| **Multi-Projekt** | **Nativ** | Plugin | Plugin | - | - |
|
||||
| **Suche** | **Eingebaut** | Algolia (Cloud) | Eingebaut | MiniSearch | Cloud |
|
||||
| **AI-Kontext (`llms.txt`)** | **Eingebaut** | - | - | - | Eingebaut |
|
||||
| **MCP-Server** | **Eingebaut** | - | - | - | Eingebaut |
|
||||
| **Agent Skills** | **Eingebaut** | - | - | - | - |
|
||||
| **Docker-Image** | **Offiziell** | - | Offiziell | - | - |
|
||||
| **Self-hosted** | **Ja** | Ja | Ja | Ja | - |
|
||||
| **Kosten** | **Frei (OSS)** | Frei (OSS) | Frei (OSS) | Frei (OSS) | Freemium |
|
||||
|
||||
## Features
|
||||
|
||||
### Zero Config, sofortiger Start
|
||||
Zeigen Sie docmd auf einen beliebigen Markdown-Ordner und es läuft. Die Navigation wird automatisch aus Ihrer Verzeichnisstruktur erstellt. Sie können Ihre erste Doku schreiben und in unter einer Minute live haben — kein Boilerplate, keine zu konfigurierende Build-Pipeline, keine Vorab-Entscheidungen.
|
||||
|
||||
### Winzig standardmäßig, schnell überall
|
||||
Der Standard-JavaScript-Payload ist ~18 kb. Seiten navigieren als sofortige SPA. Die Ausgabe ist statisches HTML — SEO-optimiert, mit Sitemap, kanonischen URLs und Open-Graph-Metadaten. Offline-Volltextsuche ist eingebaut, kein Cloud-Dienst nötig.
|
||||
|
||||
### AI-nativ
|
||||
docmd ist für die Art gebaut, wie Dokumentation heute gelesen und genutzt wird:
|
||||
- **MCP-Server** — `docmd mcp` stellt Ihre Doku AI-Agenten über stdio zur Verfügung, damit diese direkt suchen, lesen und Inhalte validieren können.
|
||||
- **Kontext (`llms.txt` / `llms-full.txt`)** — vollständiger Dokumentations-Kontext, zur Build-Zeit generiert, bereit für jedes LLM.
|
||||
- **Agent Skills** — modulare Anleitungs-Sets für LLMs und IDE-Agenten ([docmd-skills](https://github.com/docmd-io/docmd-skills)).
|
||||
- **Als Markdown kopieren / Kontext kopieren** — Ein-Klick-Buttons im Browser, optimiert zum Einfügen in AI-Chats.
|
||||
|
||||
### Auf Skalierung ausgelegt
|
||||
- Internationalisierung mit Multi-Locale-Builds
|
||||
- Versionierung für mehrere Dokumentations-Releases
|
||||
- Workspaces für Monorepos und Multi-Projekt-Setups
|
||||
- Plugin-System zur Erweiterung der Kern-Funktionalität
|
||||
- Volle Theming-Unterstützung, eingebaute Templates, eigenes CSS/JS, Light/Dark-Mode
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
docmd dev # lokaler Dev-Server
|
||||
docmd build # Für Deployment bauen
|
||||
docmd live # Browser-basierter Live-Editor
|
||||
docmd init # neue docmd.config.json im aktuellen Ordner anlegen
|
||||
docmd stop # laufende `docmd dev` / `docmd live` Server stoppen
|
||||
docmd doctor # Vorab-Check: Konfiguration + Plugin-Installationsstatus
|
||||
docmd migrate # Import aus Docusaurus, VitePress, MkDocs oder Starlight
|
||||
docmd deploy # Config für Docker, NGINX, Caddy, Vercel, Netlify generieren
|
||||
docmd validate # Alle internen Links prüfen
|
||||
docmd mcp # Als MCP-Server über stdio betreiben
|
||||
docmd add <name> # Plugin oder Template installieren
|
||||
```
|
||||
|
||||
## Plugins
|
||||
|
||||
Die Kern-Funktionalität wird von einem robusten Plugin-System bereitgestellt. Die Grundlagen sind standardmäßig enthalten, optionale Plugins können für spezifische Bedürfnisse hinzugefügt werden.
|
||||
|
||||
| Plugin | Status | Beschreibung |
|
||||
| :--- | :---: | :--- |
|
||||
| `search` | Kern | Offline-Volltextsuche mit Fuzzy-Matching |
|
||||
| `seo` | Kern | SEO-Tags und Open-Graph-Metadaten |
|
||||
| `sitemap` | Kern | Generiert `sitemap.xml` |
|
||||
| `git` | Kern | Git-Commit-Historie und letzte Aktualisierungsdaten |
|
||||
| `analytics` | Kern | Schlanke Analytics-Integration |
|
||||
| `llms` | Kern | AI-Kontext-Generierung (`llms.txt` / `llms-full.txt`) |
|
||||
| `mermaid` | Kern | Mermaid-Diagramm-Unterstützung |
|
||||
| `openapi` | Kern | Build-Time-OpenAPI-3.x-Spec-Renderer |
|
||||
| `okf` | Core | Open Knowledge Format Bundles für KI-Agenten (pro Locale) |
|
||||
| `pwa` | Optional | Progressive Web App — Offline-Navigation |
|
||||
| `threads` | Optional | Inline-Diskussions-Threads *(von @svallory)* |
|
||||
| `math` | Optional | KaTeX / LaTeX-Mathematik-Rendering |
|
||||
|
||||
Optionale Plugins installieren:
|
||||
|
||||
```bash
|
||||
docmd add <plugin-name>
|
||||
```
|
||||
|
||||
Eigene bauen: [Plugin-Entwicklungs-Leitfaden](https://docs.docmd.io/development/building-plugins/)
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Keine Konfiguration ist nötig, um zu starten. Fügen Sie eine `docmd.config.json` (oder `.ts` / `.js`) im Projektstamm nur dann hinzu, wenn Sie mehr Kontrolle brauchen:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Mein Projekt",
|
||||
"url": "https://docs.meinprojekt.de",
|
||||
"src": "./docs",
|
||||
"out": "./dist"
|
||||
}
|
||||
```
|
||||
|
||||
TypeScript- und JavaScript-Konfigurationsdateien werden für dynamische Werte unterstützt.
|
||||
|
||||
Vollständige Referenz: [Konfigurations-Übersicht](https://docs.docmd.io/configuration/overview)
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```text
|
||||
my-docs/
|
||||
├── docs/ ← Ihre Markdown-Dateien
|
||||
├── assets/ ← Bilder und statische Dateien
|
||||
├── docmd.config.json ← Optionale Konfiguration
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Live-Editor
|
||||
|
||||
Ein browserbasierter Editor zum Schreiben und Vorschauen von Doku — kein lokales Setup erforderlich.
|
||||
|
||||
<p>
|
||||
<img alt="docmd Live-Editor Vorschau" width="820" src="https://docs.docmd.io/assets/previews/live-editor-preview.webp">
|
||||
</p>
|
||||
|
||||
**Probieren Sie es aus auf [live.docmd.io](https://live.docmd.io)**
|
||||
|
||||
## Programmatische API
|
||||
|
||||
Verwenden Sie docmd in Node.js-Skripten, CI-Pipelines oder benutzerdefinierten Build-Schritten. (Unterstützt sowohl CommonJS als auch ESM.)
|
||||
|
||||
```javascript
|
||||
import { build } from '@docmd/core';
|
||||
|
||||
await build('./docmd.config.json', { isDev: false });
|
||||
```
|
||||
|
||||
Vollständige Referenz: [Node-API](https://docs.docmd.io/development/node-api-reference/)
|
||||
|
||||
## Community
|
||||
|
||||
- **Bugs & Probleme** → [GitHub Issues](https://github.com/docmd-io/docmd/issues)
|
||||
- **Fragen & Ideen** → [Discussions](https://github.com/orgs/docmd-io/discussions)
|
||||
- **Beitragen** → [CONTRIBUTING.md](.github/CONTRIBUTING.md)
|
||||
- **Roadmap** → [GitHub Discussions](https://github.com/orgs/docmd-io/discussions/2)
|
||||
|
||||
## Unterstützung
|
||||
|
||||
- docmd bekannt zu machen ist der direkteste Weg, seine Entwicklung zu unterstützen. [Teilen Sie es auf X](https://twitter.com/intent/tweet?url=https://github.com/docmd-io/docmd&text=docmd%20-%20Produktionsreife%20Doku%20aus%20Markdown%20in%20Sekunden.) mit Freunden oder geben Sie ihm einen Stern.
|
||||
- Falls docmd Ihnen Zeit spart, hilft ein [GitHub-Sponsoring](https://github.com/sponsors/mgks) sehr weiter.
|
||||
- Ideen oder Bugs? Eröffnen Sie ein Issue oder eine PR, gerne auch mit eigenen Plugins.
|
||||
|
||||
## Lizenz
|
||||
|
||||
MIT — siehe [`LICENSE`](./LICENSE) für Details.
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
<div align="right">
|
||||
<sup>
|
||||
<a href="./README.md">EN</a> | <b>ES</b> | <a href="./README.de.md">DE</a> | <a href="./README.ja.md">日本語</a> | <a href="./README.fr.md">FR</a> | <a href="./README.zh.md">中文</a>
|
||||
</sup>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://docmd.io">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/docmd-io/docmd/blob/main/packages/ui/assets/images/docmd-logo-dark.png?raw=true" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://github.com/docmd-io/docmd/blob/main/packages/ui/assets/images/docmd-logo-dark.png?raw=true" />
|
||||
<img src="https://github.com/docmd-io/docmd/blob/main/packages/ui/assets/images/docmd-logo-dark.png?raw=true" alt="docmd" width="210" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
<br/>
|
||||
|
||||
<p><b>Documentación lista para producción desde Markdown, en segundos.</b><br/>Zero config. AI-nativo. Construido para desarrolladores.</p>
|
||||
|
||||
<p>
|
||||
<a href="https://www.npmjs.com/package/@docmd/core"><img src="https://img.shields.io/npm/v/@docmd/core.svg?style=flat-square&color=CB3837" alt="npm version"></a>
|
||||
<a href="https://www.npmjs.com/package/@docmd/core?activeTab=versions"><img src="https://img.shields.io/npm/dm/@docmd/core.svg?style=flat-square&color=38bd24" alt="descargas mensuales"></a>
|
||||
<a href="https://github.com/docmd-io/docmd"><img src="https://img.shields.io/github/stars/docmd-io/docmd?style=flat-square&logo=github" alt="estrellas de GitHub"></a>
|
||||
<a href="https://github.com/docmd-io/docmd/blob/main/LICENSE"><img src="https://img.shields.io/github/license/docmd-io/docmd.svg?style=flat-square&color=A31F34" alt="licencia"></a>
|
||||
</p>
|
||||
|
||||
<h4>
|
||||
<a href="https://docmd.io">Sitio web</a> ·
|
||||
<a href="https://docs.docmd.io">Documentación</a> ·
|
||||
<a href="https://live.docmd.io">Editor en vivo</a> ·
|
||||
<a href="https://github.com/docmd-io/docmd-skills">Agent Skills</a> ·
|
||||
<a href="https://github.com/docmd-io/docmd/issues">Reportar un bug</a>
|
||||
</h4>
|
||||
|
||||
<br/>
|
||||
|
||||
<a href="https://docs.docmd.io">
|
||||
<img width="820" alt="docmd tema por defecto — vista previa en modo claro y oscuro" src="https://raw.githubusercontent.com/docmd-io/docmd/refs/heads/main/assets/docmd-cover.webp" />
|
||||
</a>
|
||||
|
||||
<br/><br/>
|
||||
|
||||
</div>
|
||||
|
||||
## Inicio rápido
|
||||
|
||||
Ejecuta docmd en cualquier carpeta con archivos Markdown — sin instalación:
|
||||
|
||||
```bash
|
||||
npx @docmd/core dev
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>Abre en <code>http://localhost:3000</code></b></summary><br>
|
||||
|
||||
```bash
|
||||
_ _
|
||||
_| |___ ___ _____ _| |
|
||||
| . | . | _| | . |
|
||||
|___|___|___|_|_|_|___|
|
||||
|
||||
v1.x.x
|
||||
|
||||
┌─ Build
|
||||
│ Engine JS
|
||||
│ Source docs/
|
||||
│ Output site/
|
||||
│ Versions 2 (06, 05)
|
||||
│ Locales 7 (en, hi, zh, es, de, ja, fr)
|
||||
└──────────────────────────────────────────────────────────
|
||||
┌─ Data Indexing
|
||||
│ [ DONE ] Syncing git metadata
|
||||
│ [ DONE ] Building semantic search index (multi-version)
|
||||
└──────────────────────────────────────────────────────────
|
||||
┌─ Publishing
|
||||
│ [ DONE ] Generated robots.txt
|
||||
│ [ DONE ] Generated .nojekyll (disables Jekyll on GitHub Pages)
|
||||
│ [ DONE ] Generated sitemap
|
||||
│ [ DONE ] Generating LLMs context files
|
||||
└──────────────────────────────────────────────────────────
|
||||
|
||||
⬢ Initial build completed in 1.2s.
|
||||
|
||||
┌─ Watching
|
||||
│ Source ./docs
|
||||
│ Config ./docmd.config.json
|
||||
│ Assets ./assets
|
||||
└──────────────────────────────────────────────────────────
|
||||
┌─ Development Server Running
|
||||
│ Local Access http://127.0.0.1:3000
|
||||
│ Network Access http://192.168.1.6:3000
|
||||
│ Serving from ./site
|
||||
└──────────────────────────────────────────────────────────
|
||||
```
|
||||
</details>
|
||||
|
||||
<p align="center">
|
||||
<img alt="vista previa del servidor de desarrollo docmd" width="820" src="https://docmd.io/assets/images/dev-preview.gif">
|
||||
</p>
|
||||
|
||||
La navegación se genera a partir de la estructura de archivos. Sin archivo de configuración, sin frontmatter obligatorio, sin framework que aprender.
|
||||
|
||||
**Cuando estés listo para publicar:**
|
||||
|
||||
```bash
|
||||
npx @docmd/core build
|
||||
```
|
||||
|
||||
Esto produce un sitio estático altamente optimizado (SPA) listo para desplegar en Vercel, Cloudflare Pages, Netlify, GitHub Pages o cualquier host estático.
|
||||
|
||||
**Requisitos:** Node.js 18+
|
||||
|
||||
<details>
|
||||
<summary><b>O instala globalmente / vía Docker</b></summary><br/>
|
||||
|
||||
```bash
|
||||
# Instalar globalmente vía npm
|
||||
npm install -g @docmd/core
|
||||
|
||||
# O vía pnpm
|
||||
pnpm add -g @docmd/core
|
||||
|
||||
# Ejecutarlo
|
||||
docmd dev # iniciar el servidor de desarrollo
|
||||
docmd build # construir para desplegar
|
||||
```
|
||||
|
||||
O ejecuta vía Docker:
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 ghcr.io/docmd-io/docmd:0.8.7
|
||||
```
|
||||
|
||||
> Fije una versión para compilaciones reproducibles.
|
||||
|
||||
</details>
|
||||
|
||||
## ¿Por qué docmd?
|
||||
|
||||
| Característica | docmd | Docusaurus | MkDocs | VitePress | Mintlify |
|
||||
| :--- | :---: | :---: | :---: | :---: | :---: |
|
||||
| **Configuración requerida** | **Ninguna** | `docusaurus.config.js` | `mkdocs.yml` | `config.mts` | `docs.json` |
|
||||
| **Payload JS** | **~18 kb** | ~250 kb | ~40 kb | ~50 kb | ~120 kb |
|
||||
| **Navegación** | **SPA instantánea** | React SPA | Recarga completa | Vue SPA | SPA alojada |
|
||||
| **Versionado** | **Nativo** | Nativo (complejo) | Plugin mike | Manual | Nativo |
|
||||
| **i18n** | **Nativo** | Nativo (complejo) | Basado en plugin | Nativo | Nativo |
|
||||
| **Multi-proyecto** | **Nativo** | Plugin | Plugin | - | - |
|
||||
| **Búsqueda** | **Integrada** | Algolia (nube) | Integrada | MiniSearch | Nube |
|
||||
| **Contexto IA (`llms.txt`)** | **Integrado** | - | - | - | Integrado |
|
||||
| **Servidor MCP** | **Integrado** | - | - | - | Integrado |
|
||||
| **Agent Skills** | **Integrado** | - | - | - | - |
|
||||
| **Imagen Docker** | **Oficial** | - | Oficial | - | - |
|
||||
| **Autoalojado** | **Sí** | Sí | Sí | Sí | - |
|
||||
| **Coste** | **Gratis (OSS)** | Gratis (OSS) | Gratis (OSS) | Gratis (OSS) | Freemium |
|
||||
|
||||
## Funcionalidades
|
||||
|
||||
### Zero config, inicio instantáneo
|
||||
Apunta docmd a cualquier carpeta de Markdown y se ejecuta. La navegación se genera automáticamente a partir de la estructura de archivos. Puedes escribir tu primer doc y tenerlo en vivo en menos de un minuto — sin boilerplate, sin pipeline de build que configurar, sin decisiones que tomar por adelantado.
|
||||
|
||||
### Diminuto por defecto, rápido en todas partes
|
||||
El payload de JavaScript por defecto es ~18 kb. Las páginas navegan como una SPA instantánea. La salida es HTML estático — optimizado para SEO, con sitemap, URLs canónicas y metadatos Open Graph incluidos. Búsqueda offline de texto completo integrada, sin servicio en la nube necesario.
|
||||
|
||||
### AI-nativo
|
||||
docmd está construido para la forma en que la documentación se lee y se usa hoy:
|
||||
- **Servidor MCP** — `docmd mcp` expone tu documentación a agentes de IA sobre stdio, permitiéndoles buscar, leer y validar contenido directamente.
|
||||
- **Contexto (`llms.txt` / `llms-full.txt`)** — contexto completo de documentación generado en tiempo de build, listo para cualquier LLM.
|
||||
- **Agent Skills** — conjuntos de instrucciones modulares para LLMs y agentes de IDE ([docmd-skills](https://github.com/docmd-io/docmd-skills)).
|
||||
- **Copiar como Markdown / Copiar contexto** — botones de un clic en el navegador, optimizados para pegar en chats de IA.
|
||||
|
||||
### Construido para escalar
|
||||
- Internacionalización con builds multi-idioma
|
||||
- Versionado para múltiples releases de documentación
|
||||
- Workspaces para monorepos y setups multi-proyecto
|
||||
- Sistema de plugins para extender el comportamiento del núcleo
|
||||
- Soporte completo de temas, plantillas integradas, CSS/JS personalizado, modo claro/oscuro
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
docmd dev # servidor de desarrollo local
|
||||
docmd build # construir para desplegar
|
||||
docmd live # Editor en vivo basado en navegador
|
||||
docmd init # crear un nuevo docmd.config.json en la carpeta actual
|
||||
docmd stop # detener servidores `docmd dev` / `docmd live` en ejecución
|
||||
docmd doctor # verificación previa: config + estado de instalación de plugins
|
||||
docmd migrate # importar desde Docusaurus, VitePress, MkDocs o Starlight
|
||||
docmd deploy # generar configuración para Docker, NGINX, Caddy, Vercel, Netlify
|
||||
docmd validate # comprobar todos los enlaces internos
|
||||
docmd mcp # ejecutar como servidor MCP sobre stdio
|
||||
docmd add <name> # instalar un plugin o plantilla
|
||||
```
|
||||
|
||||
## Plugins
|
||||
|
||||
La funcionalidad principal está impulsada por un sistema de plugins robusto. Lo esencial viene incluido por defecto, mientras que los plugins opcionales pueden añadirse según necesidades específicas.
|
||||
|
||||
| Plugin | Estado | Descripción |
|
||||
| :--- | :---: | :--- |
|
||||
| `search` | Núcleo | Búsqueda offline de texto completo con coincidencia difusa |
|
||||
| `seo` | Núcleo | Etiquetas SEO y metadatos Open Graph |
|
||||
| `sitemap` | Núcleo | Genera `sitemap.xml` |
|
||||
| `git` | Núcleo | Historial de commits de Git y fechas de última actualización |
|
||||
| `analytics` | Núcleo | Integración ligera de analytics |
|
||||
| `llms` | Núcleo | Generación de contexto IA (`llms.txt` / `llms-full.txt`) |
|
||||
| `mermaid` | Núcleo | Soporte de diagramas Mermaid |
|
||||
| `openapi` | Núcleo | Renderizador de especificación OpenAPI 3.x en tiempo de build |
|
||||
| `okf` | Core | Bundles Open Knowledge Format para agentes IA (por locale) |
|
||||
| `pwa` | Opcional | Progressive Web App — navegación offline |
|
||||
| `threads` | Opcional | Hilos de discusión inline *(por @svallory)* |
|
||||
| `math` | Opcional | Renderizado matemático KaTeX / LaTeX |
|
||||
|
||||
Instalar plugins opcionales:
|
||||
|
||||
```bash
|
||||
docmd add <plugin-name>
|
||||
```
|
||||
|
||||
Crea el tuyo: [Guía de desarrollo de plugins](https://docs.docmd.io/development/building-plugins/)
|
||||
|
||||
## Configuración
|
||||
|
||||
No se requiere configuración para comenzar. Añade un `docmd.config.json` (o `.ts` / `.js`) en la raíz de tu proyecto solo cuando necesites más control:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Mi Proyecto",
|
||||
"url": "https://docs.miproyecto.com",
|
||||
"src": "./docs",
|
||||
"out": "./dist"
|
||||
}
|
||||
```
|
||||
|
||||
Se admiten archivos de configuración TypeScript y JavaScript para valores dinámicos.
|
||||
|
||||
Referencia completa: [Resumen de configuración](https://docs.docmd.io/configuration/overview)
|
||||
|
||||
## Estructura del proyecto
|
||||
|
||||
```text
|
||||
my-docs/
|
||||
├── docs/ ← Tus archivos markdown
|
||||
├── assets/ ← Imágenes y archivos estáticos
|
||||
├── docmd.config.json ← Configuración opcional
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Editor en vivo
|
||||
|
||||
Un editor basado en navegador para escribir y previsualizar documentación — sin configuración local.
|
||||
|
||||
<p>
|
||||
<img alt="vista previa del editor en vivo docmd" width="820" src="https://docs.docmd.io/assets/previews/live-editor-preview.webp">
|
||||
</p>
|
||||
|
||||
**Pruébalo en [live.docmd.io](https://live.docmd.io)**
|
||||
|
||||
## API programática
|
||||
|
||||
Usa docmd en scripts de Node.js, pipelines de CI o pasos de build personalizados. (Soporta tanto CommonJS como ESM.)
|
||||
|
||||
```javascript
|
||||
import { build } from '@docmd/core';
|
||||
|
||||
await build('./docmd.config.json', { isDev: false });
|
||||
```
|
||||
|
||||
Referencia completa: [Node API](https://docs.docmd.io/development/node-api-reference/)
|
||||
|
||||
## Comunidad
|
||||
|
||||
- **Bugs y problemas** → [GitHub Issues](https://github.com/docmd-io/docmd/issues)
|
||||
- **Preguntas e ideas** → [Discussions](https://github.com/orgs/docmd-io/discussions)
|
||||
- **Contribuir** → [CONTRIBUTING.md](.github/CONTRIBUTING.md)
|
||||
- **Roadmap** → [GitHub Discussions](https://github.com/orgs/docmd-io/discussions/2)
|
||||
|
||||
## Soporte
|
||||
|
||||
- Dar a conocer docmd es la forma más directa de apoyar su desarrollo. [Compártelo en X](https://twitter.com/intent/tweet?url=https://github.com/docmd-io/docmd&text=docmd%20-%20Documentación%20lista%20para%20producción%20desde%20Markdown%20en%20segundos.) con amigos o dale una estrella.
|
||||
- Si docmd te ahorra tiempo, un [sponsorship en GitHub](https://github.com/sponsors/mgks) ayuda mucho.
|
||||
- ¿Ideas o bugs? Abre un issue o PR, siéntete libre de contribuir con tus propios plugins.
|
||||
|
||||
## Licencia
|
||||
|
||||
MIT — consulta [`LICENSE`](./LICENSE) para más detalles.
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
<div align="right">
|
||||
<sup>
|
||||
<a href="./README.md">EN</a> | <a href="./README.es.md">ES</a> | <a href="./README.de.md">DE</a> | <a href="./README.ja.md">日本語</a> | <b>FR</b> | <a href="./README.zh.md">中文</a>
|
||||
</sup>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://docmd.io">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/docmd-io/docmd/blob/main/packages/ui/assets/images/docmd-logo-dark.png?raw=true" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://github.com/docmd-io/docmd/blob/main/packages/ui/assets/images/docmd-logo-dark.png?raw=true" />
|
||||
<img src="https://github.com/docmd-io/docmd/blob/main/packages/ui/assets/images/docmd-logo-dark.png?raw=true" alt="docmd" width="210" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
<br/>
|
||||
|
||||
<p><b>Documentation prête pour la production à partir de Markdown, en quelques secondes.</b><br/>Zero config. Native IA. Conçu pour les développeurs.</p>
|
||||
|
||||
<p>
|
||||
<a href="https://www.npmjs.com/package/@docmd/core"><img src="https://img.shields.io/npm/v/@docmd/core.svg?style=flat-square&color=CB3837" alt="npm version"></a>
|
||||
<a href="https://www.npmjs.com/package/@docmd/core?activeTab=versions"><img src="https://img.shields.io/npm/dm/@docmd/core.svg?style=flat-square&color=38bd24" alt="téléchargements mensuels"></a>
|
||||
<a href="https://github.com/docmd-io/docmd"><img src="https://img.shields.io/github/stars/docmd-io/docmd?style=flat-square&logo=github" alt="étoiles GitHub"></a>
|
||||
<a href="https://github.com/docmd-io/docmd/blob/main/LICENSE"><img src="https://img.shields.io/github/license/docmd-io/docmd.svg?style=flat-square&color=A31F34" alt="licence"></a>
|
||||
</p>
|
||||
|
||||
<h4>
|
||||
<a href="https://docmd.io">Site web</a> ·
|
||||
<a href="https://docs.docmd.io">Documentation</a> ·
|
||||
<a href="https://live.docmd.io">Éditeur en direct</a> ·
|
||||
<a href="https://github.com/docmd-io/docmd-skills">Agent Skills</a> ·
|
||||
<a href="https://github.com/docmd-io/docmd/issues">Signaler un bug</a>
|
||||
</h4>
|
||||
|
||||
<br/>
|
||||
|
||||
<a href="https://docs.docmd.io">
|
||||
<img width="820" alt="docmd thème par défaut — aperçu en mode clair et sombre" src="https://raw.githubusercontent.com/docmd-io/docmd/refs/heads/main/assets/docmd-cover.webp" />
|
||||
</a>
|
||||
|
||||
<br/><br/>
|
||||
|
||||
</div>
|
||||
|
||||
## Démarrage rapide
|
||||
|
||||
Lancez docmd dans n'importe quel dossier contenant des fichiers Markdown — aucune installation requise :
|
||||
|
||||
```bash
|
||||
npx @docmd/core dev
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>Ouvre sur <code>http://localhost:3000</code></b></summary><br>
|
||||
|
||||
```bash
|
||||
_ _
|
||||
_| |___ ___ _____ _| |
|
||||
| . | . | _| | . |
|
||||
|___|___|___|_|_|_|___|
|
||||
|
||||
v1.x.x
|
||||
|
||||
┌─ Build
|
||||
│ Engine JS
|
||||
│ Source docs/
|
||||
│ Output site/
|
||||
│ Versions 2 (06, 05)
|
||||
│ Locales 7 (en, hi, zh, es, de, ja, fr)
|
||||
└──────────────────────────────────────────────────────────
|
||||
┌─ Data Indexing
|
||||
│ [ DONE ] Syncing git metadata
|
||||
│ [ DONE ] Building semantic search index (multi-version)
|
||||
└──────────────────────────────────────────────────────────
|
||||
┌─ Publishing
|
||||
│ [ DONE ] Generated robots.txt
|
||||
│ [ DONE ] Generated .nojekyll (disables Jekyll on GitHub Pages)
|
||||
│ [ DONE ] Generated sitemap
|
||||
│ [ DONE ] Generating LLMs context files
|
||||
└──────────────────────────────────────────────────────────
|
||||
|
||||
⬢ Initial build completed in 1.2s.
|
||||
|
||||
┌─ Watching
|
||||
│ Source ./docs
|
||||
│ Config ./docmd.config.json
|
||||
│ Assets ./assets
|
||||
└──────────────────────────────────────────────────────────
|
||||
┌─ Development Server Running
|
||||
│ Local Access http://127.0.0.1:3000
|
||||
│ Network Access http://192.168.1.6:3000
|
||||
│ Serving from ./site
|
||||
└──────────────────────────────────────────────────────────
|
||||
```
|
||||
</details>
|
||||
|
||||
<p align="center">
|
||||
<img alt="aperçu du serveur de développement docmd" width="820" src="https://docmd.io/assets/images/dev-preview.gif">
|
||||
</p>
|
||||
|
||||
La navigation est générée à partir de votre structure de fichiers. Pas de fichier de configuration, pas de frontmatter obligatoire, pas de framework à apprendre.
|
||||
|
||||
**Quand vous êtes prêt à publier :**
|
||||
|
||||
```bash
|
||||
npx @docmd/core build
|
||||
```
|
||||
|
||||
Cela produit un site statique (SPA) hautement optimisé, prêt à être déployé sur Vercel, Cloudflare Pages, Netlify, GitHub Pages, ou n'importe quel hébergeur statique.
|
||||
|
||||
**Prérequis :** Node.js 18+
|
||||
|
||||
<details>
|
||||
<summary><b>Ou installer globalement / via Docker</b></summary><br/>
|
||||
|
||||
```bash
|
||||
# Installer globalement via npm
|
||||
npm install -g @docmd/core
|
||||
|
||||
# Ou via pnpm
|
||||
pnpm add -g @docmd/core
|
||||
|
||||
# Lancer
|
||||
docmd dev # démarrer le serveur de développement
|
||||
docmd build # construire pour le déploiement
|
||||
```
|
||||
|
||||
Ou via Docker :
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 ghcr.io/docmd-io/docmd:0.8.7
|
||||
```
|
||||
|
||||
> Épinglez une version pour des builds reproductibles.
|
||||
|
||||
</details>
|
||||
|
||||
## Pourquoi docmd ?
|
||||
|
||||
| Fonctionnalité | docmd | Docusaurus | MkDocs | VitePress | Mintlify |
|
||||
| :--- | :---: | :---: | :---: | :---: | :---: |
|
||||
| **Configuration requise** | **Aucune** | `docusaurus.config.js` | `mkdocs.yml` | `config.mts` | `docs.json` |
|
||||
| **Payload JS** | **~18 ko** | ~250 ko | ~40 ko | ~50 ko | ~120 ko |
|
||||
| **Navigation** | **SPA instantanée** | React SPA | Rechargement complet | Vue SPA | SPA hébergée |
|
||||
| **Versioning** | **Natif** | Natif (complexe) | Plugin mike | Manuel | Natif |
|
||||
| **i18n** | **Natif** | Natif (complexe) | Basé sur plugin | Natif | Natif |
|
||||
| **Multi-projet** | **Natif** | Plugin | Plugin | - | - |
|
||||
| **Recherche** | **Intégrée** | Algolia (cloud) | Intégrée | MiniSearch | Cloud |
|
||||
| **Contexte IA (`llms.txt`)** | **Intégré** | - | - | - | Intégré |
|
||||
| **Serveur MCP** | **Intégré** | - | - | - | Intégré |
|
||||
| **Agent Skills** | **Intégré** | - | - | - | - |
|
||||
| **Image Docker** | **Officielle** | - | Officielle | - | - |
|
||||
| **Auto-hébergé** | **Oui** | Oui | Oui | Oui | - |
|
||||
| **Coût** | **Libre (OSS)** | Libre (OSS) | Libre (OSS) | Libre (OSS) | Freemium |
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
### Zéro config, démarrage instantané
|
||||
Pointez docmd vers n'importe quel dossier de Markdown et il s'exécute. La navigation est générée automatiquement à partir de votre structure de fichiers. Vous pouvez écrire votre premier doc et l'avoir en ligne en moins d'une minute — pas de boilerplate, pas de pipeline de build à configurer, pas de décisions à prendre à l'avance.
|
||||
|
||||
### Minuscule par défaut, rapide partout
|
||||
Le payload JavaScript par défaut est de ~18 ko. Les pages naviguent comme une SPA instantanée. La sortie est du HTML statique — optimisé pour le SEO, avec sitemap, URLs canoniques et métadonnées Open Graph intégrées. Recherche full-text hors ligne intégrée, sans service cloud requis.
|
||||
|
||||
### Native IA
|
||||
docmd est conçu pour la façon dont la documentation est lue et utilisée aujourd'hui :
|
||||
- **Serveur MCP** — `docmd mcp` expose votre documentation aux agents IA sur stdio, leur permettant de chercher, lire et valider le contenu directement.
|
||||
- **Contexte (`llms.txt` / `llms-full.txt`)** — contexte complet de la documentation généré au build, prêt pour tout LLM.
|
||||
- **Agent Skills** — ensembles d'instructions modulaires pour les LLMs et les agents IDE ([docmd-skills](https://github.com/docmd-io/docmd-skills)).
|
||||
- **Copier en Markdown / Copier le contexte** — boutons en un clic dans le navigateur, optimisés pour coller dans un chat IA.
|
||||
|
||||
### Conçu pour passer à l'échelle
|
||||
- Internationalisation avec builds multilingues
|
||||
- Versioning pour plusieurs versions de documentation
|
||||
- Workspaces pour les monorepos et les configurations multi-projets
|
||||
- Système de plugins pour étendre le comportement du cœur
|
||||
- Support complet du theming, modèles intégrés, CSS/JS personnalisé, mode clair/sombre
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
docmd dev # serveur de développement local
|
||||
docmd build # construire pour le déploiement
|
||||
docmd live # éditeur en direct basé sur le navigateur
|
||||
docmd init # générer un nouveau docmd.config.json dans le dossier courant
|
||||
docmd stop # arrêter les serveurs `docmd dev` / `docmd live` en cours
|
||||
docmd doctor # vérification préalable : config + statut d'installation des plugins
|
||||
docmd migrate # importer depuis Docusaurus, VitePress, MkDocs ou Starlight
|
||||
docmd deploy # générer la configuration pour Docker, NGINX, Caddy, Vercel, Netlify
|
||||
docmd validate # vérifier tous les liens internes
|
||||
docmd mcp # exécuter comme serveur MCP sur stdio
|
||||
docmd add <name> # installer un plugin ou un modèle
|
||||
```
|
||||
|
||||
## Plugins
|
||||
|
||||
La fonctionnalité principale repose sur un système de plugins robuste. L'essentiel est inclus par défaut, tandis que des plugins optionnels peuvent être ajoutés selon les besoins spécifiques.
|
||||
|
||||
| Plugin | Statut | Description |
|
||||
| :--- | :---: | :--- |
|
||||
| `search` | Cœur | Recherche full-text hors ligne avec correspondance approximative |
|
||||
| `seo` | Cœur | Balises SEO et métadonnées Open Graph |
|
||||
| `sitemap` | Cœur | Génère `sitemap.xml` |
|
||||
| `git` | Cœur | Historique des commits Git et dates de dernière mise à jour |
|
||||
| `analytics` | Cœur | Intégration légère d'analytics |
|
||||
| `llms` | Cœur | Génération du contexte IA (`llms.txt` / `llms-full.txt`) |
|
||||
| `mermaid` | Cœur | Support des diagrammes Mermaid |
|
||||
| `openapi` | Cœur | Rendu de spécification OpenAPI 3.x au build |
|
||||
| `okf` | Core | Bundles Open Knowledge Format pour agents IA (par locale) |
|
||||
| `pwa` | Optionnel | Progressive Web App — navigation hors ligne |
|
||||
| `threads` | Optionnel | Fils de discussion inline *(par @svallory)* |
|
||||
| `math` | Optionnel | Rendu mathématique KaTeX / LaTeX |
|
||||
|
||||
Installer des plugins optionnels :
|
||||
|
||||
```bash
|
||||
docmd add <plugin-name>
|
||||
```
|
||||
|
||||
Créez le vôtre : [Guide de développement de plugins](https://docs.docmd.io/development/building-plugins/)
|
||||
|
||||
## Configuration
|
||||
|
||||
Aucune configuration n'est requise pour commencer. Ajoutez un `docmd.config.json` (ou `.ts` / `.js`) à la racine de votre projet uniquement lorsque vous avez besoin de plus de contrôle :
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Mon Projet",
|
||||
"url": "https://docs.monprojet.fr",
|
||||
"src": "./docs",
|
||||
"out": "./dist"
|
||||
}
|
||||
```
|
||||
|
||||
Les fichiers de configuration TypeScript et JavaScript sont pris en charge pour les valeurs dynamiques.
|
||||
|
||||
Référence complète : [Aperçu de la configuration](https://docs.docmd.io/configuration/overview)
|
||||
|
||||
## Structure du projet
|
||||
|
||||
```text
|
||||
my-docs/
|
||||
├── docs/ ← Vos fichiers markdown
|
||||
├── assets/ ← Images et fichiers statiques
|
||||
├── docmd.config.json ← Configuration optionnelle
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Éditeur en direct
|
||||
|
||||
Un éditeur basé sur navigateur pour écrire et prévisualiser la documentation — aucune configuration locale requise.
|
||||
|
||||
<p>
|
||||
<img alt="aperçu de l'éditeur en direct docmd" width="820" src="https://docs.docmd.io/assets/previews/live-editor-preview.webp">
|
||||
</p>
|
||||
|
||||
**Essayez-le sur [live.docmd.io](https://live.docmd.io)**
|
||||
|
||||
## API programmatique
|
||||
|
||||
Utilisez docmd dans des scripts Node.js, des pipelines CI ou des étapes de build personnalisées. (Supporte CommonJS et ESM.)
|
||||
|
||||
```javascript
|
||||
import { build } from '@docmd/core';
|
||||
|
||||
await build('./docmd.config.json', { isDev: false });
|
||||
```
|
||||
|
||||
Référence complète : [Node API](https://docs.docmd.io/development/node-api-reference/)
|
||||
|
||||
## Communauté
|
||||
|
||||
- **Bugs et problèmes** → [GitHub Issues](https://github.com/docmd-io/docmd/issues)
|
||||
- **Questions et idées** → [Discussions](https://github.com/orgs/docmd-io/discussions)
|
||||
- **Contribuer** → [CONTRIBUTING.md](.github/CONTRIBUTING.md)
|
||||
- **Roadmap** → [GitHub Discussions](https://github.com/orgs/docmd-io/discussions/2)
|
||||
|
||||
## Soutien
|
||||
|
||||
- Faire connaître docmd est la façon la plus directe de soutenir son développement. [Partagez-le sur X](https://twitter.com/intent/tweet?url=https://github.com/docmd-io/docmd&text=docmd%20-%20Documentation%20prête%20pour%20la%20production%20à%20partir%20de%20Markdown%20en%20quelques%20secondes.) avec vos amis ou attribuez-lui une étoile.
|
||||
- Si docmd vous fait gagner du temps, un [sponsorship GitHub](https://github.com/sponsors/mgks) est très appréciable.
|
||||
- Des idées ou des bugs ? Ouvrez une issue ou une PR, n'hésitez pas à contribuer avec vos propres plugins.
|
||||
|
||||
## Licence
|
||||
|
||||
MIT — voir [`LICENSE`](./LICENSE) pour plus de détails.
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
<div align="right">
|
||||
<sup>
|
||||
<a href="./README.md">EN</a> | <a href="./README.es.md">ES</a> | <a href="./README.de.md">DE</a> | <b>日本語</b> | <a href="./README.fr.md">FR</a> | <a href="./README.zh.md">中文</a>
|
||||
</sup>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://docmd.io">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/docmd-io/docmd/blob/main/packages/ui/assets/images/docmd-logo-dark.png?raw=true" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://github.com/docmd-io/docmd/blob/main/packages/ui/assets/images/docmd-logo-dark.png?raw=true" />
|
||||
<img src="https://github.com/docmd-io/docmd/blob/main/packages/ui/assets/images/docmd-logo-dark.png?raw=true" alt="docmd" width="210" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
<br/>
|
||||
|
||||
<p><b>Markdown から数秒でプロダクション品質のドキュメントを。</b><br/>Zero config。AI ネイティブ。開発者のために。</p>
|
||||
|
||||
<p>
|
||||
<a href="https://www.npmjs.com/package/@docmd/core"><img src="https://img.shields.io/npm/v/@docmd/core.svg?style=flat-square&color=CB3837" alt="npm version"></a>
|
||||
<a href="https://www.npmjs.com/package/@docmd/core?activeTab=versions"><img src="https://img.shields.io/npm/dm/@docmd/core.svg?style=flat-square&color=38bd24" alt="monthly downloads"></a>
|
||||
<a href="https://github.com/docmd-io/docmd"><img src="https://img.shields.io/github/stars/docmd-io/docmd?style=flat-square&logo=github" alt="GitHub stars"></a>
|
||||
<a href="https://github.com/docmd-io/docmd/blob/main/LICENSE"><img src="https://img.shields.io/github/license/docmd-io/docmd.svg?style=flat-square&color=A31F34" alt="license"></a>
|
||||
</p>
|
||||
|
||||
<h4>
|
||||
<a href="https://docmd.io">ウェブサイト</a> ·
|
||||
<a href="https://docs.docmd.io">ドキュメント</a> ·
|
||||
<a href="https://live.docmd.io">ライブエディタ</a> ·
|
||||
<a href="https://github.com/docmd-io/docmd-skills">Agent Skills</a> ·
|
||||
<a href="https://github.com/docmd-io/docmd/issues">バグ報告</a>
|
||||
</h4>
|
||||
|
||||
<br/>
|
||||
|
||||
<a href="https://docs.docmd.io">
|
||||
<img width="820" alt="docmd デフォルトテーマ — ライト/ダークモードのプレビュー" src="https://raw.githubusercontent.com/docmd-io/docmd/refs/heads/main/assets/docmd-cover.webp" />
|
||||
</a>
|
||||
|
||||
<br/><br/>
|
||||
|
||||
</div>
|
||||
|
||||
## クイックスタート
|
||||
|
||||
Markdown ファイルがある任意のフォルダで docmd を実行 — インストール不要:
|
||||
|
||||
```bash
|
||||
npx @docmd/core dev
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b><code>http://localhost:3000</code> で開きます</b></summary><br>
|
||||
|
||||
```bash
|
||||
_ _
|
||||
_| |___ ___ _____ _| |
|
||||
| . | . | _| | . |
|
||||
|___|___|___|_|_|_|___|
|
||||
|
||||
v1.x.x
|
||||
|
||||
┌─ Build
|
||||
│ Engine JS
|
||||
│ Source docs/
|
||||
│ Output site/
|
||||
│ Versions 2 (06, 05)
|
||||
│ Locales 7 (en, hi, zh, es, de, ja, fr)
|
||||
└──────────────────────────────────────────────────────────
|
||||
┌─ Data Indexing
|
||||
│ [ DONE ] Syncing git metadata
|
||||
│ [ DONE ] Building semantic search index (multi-version)
|
||||
└──────────────────────────────────────────────────────────
|
||||
┌─ Publishing
|
||||
│ [ DONE ] Generated robots.txt
|
||||
│ [ DONE ] Generated .nojekyll (disables Jekyll on GitHub Pages)
|
||||
│ [ DONE ] Generated sitemap
|
||||
│ [ DONE ] Generating LLMs context files
|
||||
└──────────────────────────────────────────────────────────
|
||||
|
||||
⬢ Initial build completed in 1.2s.
|
||||
|
||||
┌─ Watching
|
||||
│ Source ./docs
|
||||
│ Config ./docmd.config.json
|
||||
│ Assets ./assets
|
||||
└──────────────────────────────────────────────────────────
|
||||
┌─ Development Server Running
|
||||
│ Local Access http://127.0.0.1:3000
|
||||
│ Network Access http://192.168.1.6:3000
|
||||
│ Serving from ./site
|
||||
└──────────────────────────────────────────────────────────
|
||||
```
|
||||
</details>
|
||||
|
||||
<p align="center">
|
||||
<img alt="docmd dev サーバーのプレビュー" width="820" src="https://docmd.io/assets/images/dev-preview.gif">
|
||||
</p>
|
||||
|
||||
ナビゲーションはファイル構造から自動生成されます。設定ファイル不要、frontmatter 必須なし、学ぶべきフレームワークもありません。
|
||||
|
||||
**公開の準備ができたら:**
|
||||
|
||||
```bash
|
||||
npx @docmd/core build
|
||||
```
|
||||
|
||||
これは Vercel、Cloudflare Pages、Netlify、GitHub Pages、その他の静的ホストにデプロイ可能な、高度に最適化された静的サイト(SPA)を出力します。
|
||||
|
||||
**要件:** Node.js 18+
|
||||
|
||||
<details>
|
||||
<summary><b>またはグローバルインストール / Docker 経由</b></summary><br/>
|
||||
|
||||
```bash
|
||||
# npm でグローバルインストール
|
||||
npm install -g @docmd/core
|
||||
|
||||
# または pnpm
|
||||
pnpm add -g @docmd/core
|
||||
|
||||
# 実行
|
||||
docmd dev # dev サーバーを起動
|
||||
docmd build # デプロイ用にビルド
|
||||
```
|
||||
|
||||
または Docker 経由:
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 ghcr.io/docmd-io/docmd:0.8.7
|
||||
```
|
||||
|
||||
> 再現可能なビルドのためにバージョンを固定してください。
|
||||
|
||||
</details>
|
||||
|
||||
## なぜ docmd?
|
||||
|
||||
| 機能 | docmd | Docusaurus | MkDocs | VitePress | Mintlify |
|
||||
| :--- | :---: | :---: | :---: | :---: | :---: |
|
||||
| **設定が必要** | **なし** | `docusaurus.config.js` | `mkdocs.yml` | `config.mts` | `docs.json` |
|
||||
| **JS ペイロード** | **~18 kb** | ~250 kb | ~40 kb | ~50 kb | ~120 kb |
|
||||
| **ナビゲーション** | **即時 SPA** | React SPA | フルリロード | Vue SPA | ホスト型 SPA |
|
||||
| **バージョン管理** | **ネイティブ** | ネイティブ(複雑) | mike プラグイン | 手動 | ネイティブ |
|
||||
| **i18n** | **ネイティブ** | ネイティブ(複雑) | プラグインベース | ネイティブ | ネイティブ |
|
||||
| **マルチプロジェクト** | **ネイティブ** | プラグイン | プラグイン | - | - |
|
||||
| **検索** | **組み込み** | Algolia(クラウド) | 組み込み | MiniSearch | クラウド |
|
||||
| **AI コンテキスト (`llms.txt`)** | **組み込み** | - | - | - | 組み込み |
|
||||
| **MCP サーバー** | **組み込み** | - | - | - | 組み込み |
|
||||
| **Agent Skills** | **組み込み** | - | - | - | - |
|
||||
| **Docker イメージ** | **公式** | - | 公式 | - | - |
|
||||
| **セルフホスト** | **可** | 可 | 可 | 可 | - |
|
||||
| **コスト** | **無料 (OSS)** | 無料 (OSS) | 無料 (OSS) | 無料 (OSS) | フリーミアム |
|
||||
|
||||
## 機能
|
||||
|
||||
### Zero config、即時スタート
|
||||
任意の Markdown フォルダを docmd に指定するだけで実行できます。ナビゲーションはファイル構造から自動生成されます。最初のドキュメントを書いて 1 分以内に公開可能 — ボイラープレートなし、ビルドパイプラインの設定なし、事前の意思決定は不要です。
|
||||
|
||||
### 標準で軽量、どこでも高速
|
||||
デフォルトの JavaScript ペイロードは ~18 kb。ページは即時 SPA としてナビゲートします。出力は静的 HTML — SEO 最適化済み、sitemap、正規 URL、Open Graph メタデータを含む。オフラインの全文検索を内蔵、クラウドサービス不要。
|
||||
|
||||
### AI ネイティブ
|
||||
docmd は今ドキのドキュメントの読み方・使われ方に合わせて構築されています:
|
||||
- **MCP サーバー** — `docmd mcp` がドキュメントを stdio 経由で AI Agent に公開し、検索・読み取り・検証を直接行えます。
|
||||
- **コンテキスト (`llms.txt` / `llms-full.txt`)** — ビルド時に生成される完全なドキュメントコンテキスト、あらゆる LLM で即利用可能。
|
||||
- **Agent Skills** — LLM と IDE Agent 向けのモジュール式インストラクショセット ([docmd-skills](https://github.com/docmd-io/docmd-skills))。
|
||||
- **Markdown としてコピー / コンテキストをコピー** — ブラウザ内のワンクリックボタン。AI チャットへの貼り付けに最適化。
|
||||
|
||||
### スケールを見据えて構築
|
||||
- マルチロケールビルドによる国際化
|
||||
- 複数ドキュメントリリースに対応するバージョン管理
|
||||
- モノレポ・マルチプロジェクト構成向け Workspaces
|
||||
- コア機能を拡張するプラグインシステム
|
||||
- 完全なテーマ対応、ビルトインテンプレート、カスタム CSS/JS、ライト/ダークモード
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
docmd dev # ローカル dev サーバー
|
||||
docmd build # デプロイ用ビルド
|
||||
docmd live # ブラウザベースのライブエディタ
|
||||
docmd init # 現在のディレクトリに新しい docmd.config.json を作成
|
||||
docmd stop # 実行中の `docmd dev` / `docmd live` サーバーを停止
|
||||
docmd doctor # 事前チェック: 設定 + プラグインのインストール状況
|
||||
docmd migrate # Docusaurus / VitePress / MkDocs / Starlight から取り込み
|
||||
docmd deploy # Docker / NGINX / Caddy / Vercel / Netlify 用設定を生成
|
||||
docmd validate # 内部リンクを全てチェック
|
||||
docmd mcp # stdio 上で MCP サーバーとして動作
|
||||
docmd add <name> # プラグインまたはテンプレートをインストール
|
||||
```
|
||||
|
||||
## プラグイン
|
||||
|
||||
コア機能は堅牢なプラグインシステムによって支えられています。必須機能は標準で含まれており、特定のニーズに応じてオプショナルプラグインを追加できます。
|
||||
|
||||
| プラグイン | ステータス | 説明 |
|
||||
| :--- | :---: | :--- |
|
||||
| `search` | コア | あいまい一致対応のオフライン全文検索 |
|
||||
| `seo` | コア | SEO タグと Open Graph メタデータ |
|
||||
| `sitemap` | コア | `sitemap.xml` を生成 |
|
||||
| `git` | コア | Git のコミット履歴と最終更新日 |
|
||||
| `analytics` | コア | 軽量なアナリティクス連携 |
|
||||
| `llms` | コア | AI コンテキスト生成 (`llms.txt` / `llms-full.txt`) |
|
||||
| `mermaid` | コア | Mermaid 図対応 |
|
||||
| `openapi` | コア | ビルド時の OpenAPI 3.x スペックレンダラー |
|
||||
| `okf` | Core | AI エージェント向け Open Knowledge Format バンドル (ロケール別) |
|
||||
| `pwa` | オプション | Progressive Web App — オフラインナビゲーション |
|
||||
| `threads` | オプション | インラインディスカッションスレッド *(by @svallory)* |
|
||||
| `math` | オプション | KaTeX / LaTeX 数式レンダリング |
|
||||
|
||||
オプショナルプラグインのインストール:
|
||||
|
||||
```bash
|
||||
docmd add <plugin-name>
|
||||
```
|
||||
|
||||
自作:[プラグイン開発ガイド](https://docs.docmd.io/development/building-plugins/)
|
||||
|
||||
## 設定
|
||||
|
||||
始めるのに設定は不要です。プロジェクトルートに `docmd.config.json`(または `.ts` / `.js`)を追加するのは、より詳細な制御が必要な場合のみです:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "マイプロジェクト",
|
||||
"url": "https://docs.myproject.com",
|
||||
"src": "./docs",
|
||||
"out": "./dist"
|
||||
}
|
||||
```
|
||||
|
||||
TypeScript / JavaScript 形式の設定ファイルは動的な値の指定に対応しています。
|
||||
|
||||
リファレンス全体:[設定概要](https://docs.docmd.io/configuration/overview)
|
||||
|
||||
## プロジェクト構成
|
||||
|
||||
```text
|
||||
my-docs/
|
||||
├── docs/ ← あなたの Markdown ファイル
|
||||
├── assets/ ← 画像と静的ファイル
|
||||
├── docmd.config.json ← 任意の設定
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## ライブエディタ
|
||||
|
||||
ブラウザベースのエディタでドキュメントを執筆・プレビュー — ローカル環境構築は不要。
|
||||
|
||||
<p>
|
||||
<img alt="docmd ライブエディタのプレビュー" width="820" src="https://docs.docmd.io/assets/previews/live-editor-preview.webp">
|
||||
</p>
|
||||
|
||||
**[live.docmd.io](https://live.docmd.io) で試す**
|
||||
|
||||
## プログラマティック API
|
||||
|
||||
Node.js スクリプト、CI パイプライン、カスタムビルドステップで docmd を利用できます。(CommonJS / ESM の両対応。)
|
||||
|
||||
```javascript
|
||||
import { build } from '@docmd/core';
|
||||
|
||||
await build('./docmd.config.json', { isDev: false });
|
||||
```
|
||||
|
||||
リファレンス全体:[Node API](https://docs.docmd.io/development/node-api-reference/)
|
||||
|
||||
## コミュニティ
|
||||
|
||||
- **バグ報告・問題** → [GitHub Issues](https://github.com/docmd-io/docmd/issues)
|
||||
- **質問・アイデア** → [Discussions](https://github.com/orgs/docmd-io/discussions)
|
||||
- **コントリビューション** → [CONTRIBUTING.md](.github/CONTRIBUTING.md)
|
||||
- **ロードマップ** → [GitHub Discussions](https://github.com/orgs/docmd-io/discussions/2)
|
||||
|
||||
## サポート
|
||||
|
||||
- docmd の開発を最も直接的に支援する方法は、周りの人に知らせることです。X で [シェア](https://twitter.com/intent/tweet?url=https://github.com/docmd-io/docmd&text=docmd%20-%20Markdown%20から%20数秒で%20プロダクション品質の%20ドキュメントを。) したり、スターを付けるのも良いでしょう。
|
||||
- docmd があなたの時間を節約できているなら、[GitHub Sponsorship](https://github.com/sponsors/mgks) は大きな励みになります。
|
||||
- アイデアやバグがあれば Issue や PR をお気軽に。プラグインのコントリビューションも歓迎します。
|
||||
|
||||
## ライセンス
|
||||
|
||||
MIT — 詳細は [`LICENSE`](./LICENSE) を参照。
|
||||
@@ -0,0 +1,286 @@
|
||||
<div align="right">
|
||||
<sup>
|
||||
<b>EN</b> | <a href="./README.es.md">ES</a> | <a href="./README.de.md">DE</a> | <a href="./README.ja.md">日本語</a> | <a href="./README.fr.md">FR</a> | <a href="./README.zh.md">中文</a>
|
||||
</sup>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://docmd.io">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/docmd-io/docmd/blob/main/packages/ui/assets/images/docmd-logo-dark.png?raw=true" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://github.com/docmd-io/docmd/blob/main/packages/ui/assets/images/docmd-logo-dark.png?raw=true" />
|
||||
<img src="https://github.com/docmd-io/docmd/blob/main/packages/ui/assets/images/docmd-logo-dark.png?raw=true" alt="docmd" width="210" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
<br/>
|
||||
|
||||
<p><b>Production-ready documentation from Markdown, in seconds.</b><br/>Zero config. AI-native. Built for developers.</p>
|
||||
|
||||
<p>
|
||||
<a href="https://www.npmjs.com/package/@docmd/core"><img src="https://img.shields.io/npm/v/@docmd/core.svg?style=flat-square&color=CB3837" alt="npm version"></a>
|
||||
<a href="https://www.npmjs.com/package/@docmd/core?activeTab=versions"><img src="https://img.shields.io/npm/dm/@docmd/core.svg?style=flat-square&color=38bd24" alt="monthly downloads"></a>
|
||||
<a href="https://github.com/docmd-io/docmd"><img src="https://img.shields.io/github/stars/docmd-io/docmd?style=flat-square&logo=github" alt="GitHub stars"></a>
|
||||
<a href="https://github.com/docmd-io/docmd/blob/main/LICENSE"><img src="https://img.shields.io/github/license/docmd-io/docmd.svg?style=flat-square&color=A31F34" alt="license"></a>
|
||||
</p>
|
||||
|
||||
<h4>
|
||||
<a href="https://docmd.io">Website</a> ·
|
||||
<a href="https://docs.docmd.io">Documentation</a> ·
|
||||
<a href="https://live.docmd.io">Live Editor</a> ·
|
||||
<a href="https://github.com/docmd-io/docmd-skills">Agent Skills</a> ·
|
||||
<a href="https://github.com/docmd-io/docmd/issues">Report a Bug</a>
|
||||
</h4>
|
||||
|
||||
<br/>
|
||||
|
||||
<a href="https://docs.docmd.io">
|
||||
<img width="820" alt="docmd default theme — light and dark mode preview" src="https://raw.githubusercontent.com/docmd-io/docmd/refs/heads/main/assets/docmd-cover.webp" />
|
||||
</a>
|
||||
|
||||
<br/><br/>
|
||||
|
||||
</div>
|
||||
|
||||
## Quick Start
|
||||
|
||||
Run docmd in any folder with Markdown files — no install needed:
|
||||
|
||||
```bash
|
||||
npx @docmd/core dev
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>Opens at <code>http://localhost:3000</code></b></summary><br>
|
||||
|
||||
```bash
|
||||
_ _
|
||||
_| |___ ___ _____ _| |
|
||||
| . | . | _| | . |
|
||||
|___|___|___|_|_|_|___|
|
||||
|
||||
v1.x.x
|
||||
|
||||
┌─ Build
|
||||
│ Engine JS
|
||||
│ Source docs/
|
||||
│ Output site/
|
||||
│ Versions 2 (06, 05)
|
||||
│ Locales 7 (en, hi, zh, es, de, ja, fr)
|
||||
└──────────────────────────────────────────────────────────
|
||||
┌─ Data Indexing
|
||||
│ [ DONE ] Syncing git metadata
|
||||
│ [ DONE ] Building semantic search index (multi-version)
|
||||
└──────────────────────────────────────────────────────────
|
||||
┌─ Publishing
|
||||
│ [ DONE ] Generated robots.txt
|
||||
│ [ DONE ] Generated .nojekyll (disables Jekyll on GitHub Pages)
|
||||
│ [ DONE ] Generated sitemap
|
||||
│ [ DONE ] Generating LLMs context files
|
||||
└──────────────────────────────────────────────────────────
|
||||
|
||||
⬢ Initial build completed in 1.2s.
|
||||
|
||||
┌─ Watching
|
||||
│ Source ./docs
|
||||
│ Config ./docmd.config.json
|
||||
│ Assets ./assets
|
||||
└──────────────────────────────────────────────────────────
|
||||
┌─ Development Server Running
|
||||
│ Local Access http://127.0.0.1:3000
|
||||
│ Network Access http://192.168.1.6:3000
|
||||
│ Serving from ./site
|
||||
└──────────────────────────────────────────────────────────
|
||||
```
|
||||
</details>
|
||||
|
||||
<p align="center">
|
||||
<img alt="docmd dev server preview" width="820" src="https://docmd.io/assets/images/dev-preview.gif">
|
||||
</p>
|
||||
|
||||
Navigation is generated from your file structure. No config file, no frontmatter required, no framework to learn.
|
||||
|
||||
**When you're ready to ship:**
|
||||
|
||||
```bash
|
||||
npx @docmd/core build
|
||||
```
|
||||
|
||||
This outputs a highly optimized static site (SPA) ready for deployment to Vercel, Cloudflare Pages, Netlify, GitHub Pages, or any static host.
|
||||
|
||||
**Requirements:** Node.js 18+
|
||||
|
||||
<details>
|
||||
<summary><b>Or install globally / via Docker</b></summary><br/>
|
||||
|
||||
```bash
|
||||
# Install globally via npm
|
||||
npm install -g @docmd/core
|
||||
|
||||
# Or via pnpm
|
||||
pnpm add -g @docmd/core
|
||||
|
||||
# Run it
|
||||
docmd dev # start dev server
|
||||
docmd build # build for deployment
|
||||
```
|
||||
|
||||
Or run via Docker:
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 ghcr.io/docmd-io/docmd:0.8.7
|
||||
```
|
||||
|
||||
> Pin a version for reproducible builds.
|
||||
|
||||
</details>
|
||||
|
||||
## Why docmd?
|
||||
|
||||
| Feature | docmd | Docusaurus | MkDocs | VitePress | Mintlify |
|
||||
| :--- | :---: | :---: | :---: | :---: | :---: |
|
||||
| **Config required** | **None** | `docusaurus.config.js` | `mkdocs.yml` | `config.mts` | `docs.json` |
|
||||
| **JS payload** | **~18 kb** | ~250 kb | ~40 kb | ~50 kb | ~120 kb |
|
||||
| **Navigation** | **Instant SPA** | React SPA | Full reload | Vue SPA | Hosted SPA |
|
||||
| **Versioning** | **Native** | Native (complex) | mike plugin | Manual | Native |
|
||||
| **i18n** | **Native** | Native (complex) | Plugin-based | Native | Native |
|
||||
| **Multi-project** | **Native** | Plugin | Plugin | - | - |
|
||||
| **Search** | **Built-in** | Algolia (cloud) | Built-in | MiniSearch | Cloud |
|
||||
| **AI context (`llms.txt`)** | **Built-in** | - | - | - | Built-in |
|
||||
| **MCP server** | **Built-in** | - | - | - | Built-in |
|
||||
| **Agent skills** | **Built-in** | - | - | - | - |
|
||||
| **Docker image** | **Official** | - | Official | - | - |
|
||||
| **Self-hosted** | **Yes** | Yes | Yes | Yes | - |
|
||||
| **Cost** | **Free (OSS)** | Free (OSS) | Free (OSS) | Free (OSS) | Freemium |
|
||||
|
||||
## Features
|
||||
|
||||
### Zero config, instant start
|
||||
Point docmd at any Markdown folder and it runs. Navigation is built automatically from your file structure. You can write your first doc and have it live in under a minute — no boilerplate, no build pipeline to configure, no decisions to make upfront.
|
||||
|
||||
### Tiny by default, fast everywhere
|
||||
The default JavaScript payload is ~18 kb. Pages navigate as an instant SPA. The output is static HTML — SEO-optimised, with sitemap, canonical URLs, and Open Graph metadata included. Offline full-text search is built in, no cloud service required.
|
||||
|
||||
### AI-native
|
||||
docmd is built for the way documentation is read and used today:
|
||||
- **MCP Server** — `docmd mcp` exposes your docs to AI agents over stdio, letting them search, read, and validate content directly.
|
||||
- **Context (`llms.txt` / `llms-full.txt`)** — complete documentation context generated at build time, ready for any LLM.
|
||||
- **Agent Skills** — modular instruction sets for LLMs and IDE agents ([docmd-skills](https://github.com/docmd-io/docmd-skills)).
|
||||
- **Copy as Markdown / Copy Context** — one-click buttons in the browser, optimised for pasting into AI chat.
|
||||
|
||||
### Built to scale
|
||||
- Internationalisation with multi-locale builds (per-locale search index, llms, okf, hreflang)
|
||||
- Versioning for multiple doc releases (with auto-detection of the current version)
|
||||
- Workspaces for monorepos and multi-project setups
|
||||
- Plugin system for extending core behaviour (per-hook return-type validation, async-friendly)
|
||||
- Full theming support, built-in templates, custom CSS/JS, light/dark mode
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
docmd dev # local development server
|
||||
docmd build # build for deployment
|
||||
docmd live # browser-based Live Editor
|
||||
docmd init # scaffold a new docmd.config.json in the current folder
|
||||
docmd stop # stop any running `docmd dev` / `docmd live` servers
|
||||
docmd doctor # pre-flight check: config + plugin install status
|
||||
docmd migrate # migrate to docmd from Docusaurus, VitePress, MkDocs, or Starlight
|
||||
docmd deploy # generate config for Docker, NGINX, Caddy, Vercel, Netlify
|
||||
docmd validate # check all internal links
|
||||
docmd mcp # run as an MCP server over stdio
|
||||
docmd add <name> # install a plugin or template
|
||||
```
|
||||
|
||||
## Plugins
|
||||
|
||||
Core functionality is powered by a robust plugin system. The essentials are included by default, while optional plugins can be added for specific needs.
|
||||
|
||||
| Plugin | Status | Description |
|
||||
| :--- | :---: | :--- |
|
||||
| `search` | Core | Offline full-text search (keyword + optional semantic via `docmd-search`) |
|
||||
| `seo` | Core | SEO tags and Open Graph metadata |
|
||||
| `sitemap` | Core | Generates `sitemap.xml` |
|
||||
| `git` | Core | Git commit history and last-updated dates |
|
||||
| `analytics` | Core | Lightweight analytics integration |
|
||||
| `llms` | Core | AI context generation (`llms.txt` / `llms-full.txt`) |
|
||||
| `okf` | Core | Open Knowledge Format bundles for AI agents (per-locale) |
|
||||
| `mermaid` | Core | Mermaid diagram support |
|
||||
| `openapi` | Core | Build-time OpenAPI 3.x spec renderer |
|
||||
| `pwa` | Optional | Progressive Web App — offline navigation |
|
||||
| `threads` | Optional | Inline discussion threads *(by @svallory)* |
|
||||
| `math` | Optional | KaTeX / LaTeX math rendering |
|
||||
|
||||
Install optional plugins:
|
||||
|
||||
```bash
|
||||
docmd add <plugin-name>
|
||||
```
|
||||
|
||||
Build your own: [Plugin Development Guide](https://docs.docmd.io/development/building-plugins/)
|
||||
|
||||
## Configuration
|
||||
|
||||
No configuration is required to get started. Add a `docmd.config.json` (or `.ts` / `.js`) in your project root only when you need more control:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "My Project",
|
||||
"url": "https://docs.myproject.com",
|
||||
"src": "./docs",
|
||||
"out": "./dist"
|
||||
}
|
||||
```
|
||||
|
||||
TypeScript and JavaScript config files are supported for dynamic values.
|
||||
|
||||
Full reference: [Configuration Overview](https://docs.docmd.io/configuration/overview)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
my-docs/
|
||||
├── docs/ ← Your markdown files
|
||||
├── assets/ ← Images and static files
|
||||
├── docmd.config.json ← Optional configuration
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Live Editor
|
||||
|
||||
A browser-based editor for writing and previewing docs — no local setup required.
|
||||
|
||||
<p>
|
||||
<img alt="docmd live editor preview" width="820" src="https://docs.docmd.io/assets/previews/live-editor-preview.webp">
|
||||
</p>
|
||||
|
||||
**Try it at [live.docmd.io](https://live.docmd.io)**
|
||||
|
||||
## Programmatic API
|
||||
|
||||
Use docmd in Node.js scripts, CI pipelines, or custom build steps. (Supports both CommonJS and ESM).
|
||||
|
||||
```javascript
|
||||
import { build } from '@docmd/core';
|
||||
|
||||
await build('./docmd.config.json', { isDev: false });
|
||||
```
|
||||
|
||||
Full reference: [Node API](https://docs.docmd.io/development/node-api-reference/)
|
||||
|
||||
## Community
|
||||
|
||||
- **Bugs & issues** → [GitHub Issues](https://github.com/docmd-io/docmd/issues)
|
||||
- **Questions & ideas** → [Discussions](https://github.com/orgs/docmd-io/discussions)
|
||||
- **Contributing** → [CONTRIBUTING.md](.github/CONTRIBUTING.md)
|
||||
- **Roadmap** → [GitHub Discussions](https://github.com/orgs/docmd-io/discussions/2)
|
||||
|
||||
## Support
|
||||
|
||||
- Getting the word out is the most direct way to support docmd's development. [Share it on X](https://twitter.com/intent/tweet?url=https://github.com/docmd-io/docmd&text=docmd%20-%20Production-ready%20docs%20from%20Markdown%20in%20seconds.) with friends or give it a star.
|
||||
- If docmd saves you time, a [GitHub sponsorship](https://github.com/sponsors/mgks) goes a long way.
|
||||
- Got ideas or bugs? Open an issue or PR, feel free to contribute your own plugins.
|
||||
|
||||
## License
|
||||
|
||||
MIT License. See `LICENSE` for details.
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`docmd-io/docmd`
|
||||
- 原始仓库:https://github.com/docmd-io/docmd
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
<div align="right">
|
||||
<sup>
|
||||
<a href="./README.md">EN</a> | <a href="./README.es.md">ES</a> | <a href="./README.de.md">DE</a> | <a href="./README.ja.md">日本語</a> | <a href="./README.fr.md">FR</a> | <b>中文</b>
|
||||
</sup>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://docmd.io">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/docmd-io/docmd/blob/main/packages/ui/assets/images/docmd-logo-dark.png?raw=true" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://github.com/docmd-io/docmd/blob/main/packages/ui/assets/images/docmd-logo-dark.png?raw=true" />
|
||||
<img src="https://github.com/docmd-io/docmd/blob/main/packages/ui/assets/images/docmd-logo-dark.png?raw=true" alt="docmd" width="210" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
<br/>
|
||||
|
||||
<p><b>几秒内从 Markdown 生成生产可用的文档站点。</b><br/>零配置。AI 原生。为开发者打造。</p>
|
||||
|
||||
<p>
|
||||
<a href="https://www.npmjs.com/package/@docmd/core"><img src="https://img.shields.io/npm/v/@docmd/core.svg?style=flat-square&color=CB3837" alt="npm version"></a>
|
||||
<a href="https://www.npmjs.com/package/@docmd/core?activeTab=versions"><img src="https://img.shields.io/npm/dm/@docmd/core.svg?style=flat-square&color=38bd24" alt="每月下载"></a>
|
||||
<a href="https://github.com/docmd-io/docmd"><img src="https://img.shields.io/github/stars/docmd-io/docmd?style=flat-square&logo=github" alt="GitHub stars"></a>
|
||||
<a href="https://github.com/docmd-io/docmd/blob/main/LICENSE"><img src="https://img.shields.io/github/license/docmd-io/docmd.svg?style=flat-square&color=A31F34" alt="license"></a>
|
||||
</p>
|
||||
|
||||
<h4>
|
||||
<a href="https://docmd.io">官网</a> ·
|
||||
<a href="https://docs.docmd.io">文档</a> ·
|
||||
<a href="https://live.docmd.io">在线编辑器</a> ·
|
||||
<a href="https://github.com/docmd-io/docmd-skills">Agent Skills</a> ·
|
||||
<a href="https://github.com/docmd-io/docmd/issues">反馈 Bug</a>
|
||||
</h4>
|
||||
|
||||
<br/>
|
||||
|
||||
<a href="https://docs.docmd.io">
|
||||
<img width="820" alt="docmd 默认主题 — 浅色与深色模式预览" src="https://raw.githubusercontent.com/docmd-io/docmd/refs/heads/main/assets/docmd-cover.webp" />
|
||||
</a>
|
||||
|
||||
<br/><br/>
|
||||
|
||||
</div>
|
||||
|
||||
## 快速开始
|
||||
|
||||
在任何包含 Markdown 文件的目录下直接运行 docmd —— 无需安装:
|
||||
|
||||
```bash
|
||||
npx @docmd/core dev
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>在 <code>http://localhost:3000</code> 打开</b></summary><br>
|
||||
|
||||
```bash
|
||||
_ _
|
||||
_| |___ ___ _____ _| |
|
||||
| . | . | _| | . |
|
||||
|___|___|___|_|_|_|___|
|
||||
|
||||
v1.x.x
|
||||
|
||||
┌─ Build
|
||||
│ Engine JS
|
||||
│ Source docs/
|
||||
│ Output site/
|
||||
│ Versions 2 (06, 05)
|
||||
│ Locales 7 (en, hi, zh, es, de, ja, fr)
|
||||
└──────────────────────────────────────────────────────────
|
||||
┌─ Data Indexing
|
||||
│ [ DONE ] Syncing git metadata
|
||||
│ [ DONE ] Building semantic search index (multi-version)
|
||||
└──────────────────────────────────────────────────────────
|
||||
┌─ Publishing
|
||||
│ [ DONE ] Generated robots.txt
|
||||
│ [ DONE ] Generated .nojekyll (disables Jekyll on GitHub Pages)
|
||||
│ [ DONE ] Generated sitemap
|
||||
│ [ DONE ] Generating LLMs context files
|
||||
└──────────────────────────────────────────────────────────
|
||||
|
||||
⬢ Initial build completed in 1.2s.
|
||||
|
||||
┌─ Watching
|
||||
│ Source ./docs
|
||||
│ Config ./docmd.config.json
|
||||
│ Assets ./assets
|
||||
└──────────────────────────────────────────────────────────
|
||||
┌─ Development Server Running
|
||||
│ Local Access http://127.0.0.1:3000
|
||||
│ Network Access http://192.168.1.6:3000
|
||||
│ Serving from ./site
|
||||
└──────────────────────────────────────────────────────────
|
||||
```
|
||||
</details>
|
||||
|
||||
<p align="center">
|
||||
<img alt="docmd dev 服务器预览" width="820" src="https://docmd.io/assets/images/dev-preview.gif">
|
||||
</p>
|
||||
|
||||
导航由你的目录结构自动生成。无需配置文件、不强制 frontmatter、不必学习框架。
|
||||
|
||||
**当准备发布时:**
|
||||
|
||||
```bash
|
||||
npx @docmd/core build
|
||||
```
|
||||
|
||||
这会输出一个高度优化的静态站点(SPA),可直接部署到 Vercel、Cloudflare Pages、Netlify、GitHub Pages 或任何静态主机。
|
||||
|
||||
**环境要求:** Node.js 18+
|
||||
|
||||
<details>
|
||||
<summary><b>或全局安装 / 通过 Docker</b></summary><br/>
|
||||
|
||||
```bash
|
||||
# 通过 npm 全局安装
|
||||
npm install -g @docmd/core
|
||||
|
||||
# 或通过 pnpm
|
||||
pnpm add -g @docmd/core
|
||||
|
||||
# 运行
|
||||
docmd dev # 启动开发服务器
|
||||
docmd build # 构建用于部署
|
||||
```
|
||||
|
||||
或通过 Docker 运行:
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 ghcr.io/docmd-io/docmd:0.8.7
|
||||
```
|
||||
|
||||
> 固定一个版本以获得可复现的构建。
|
||||
|
||||
</details>
|
||||
|
||||
## 为什么选择 docmd?
|
||||
|
||||
| 特性 | docmd | Docusaurus | MkDocs | VitePress | Mintlify |
|
||||
| :--- | :---: | :---: | :---: | :---: | :---: |
|
||||
| **需要配置** | **无需** | `docusaurus.config.js` | `mkdocs.yml` | `config.mts` | `docs.json` |
|
||||
| **JS 体积** | **~18 kb** | ~250 kb | ~40 kb | ~50 kb | ~120 kb |
|
||||
| **导航** | **即时 SPA** | React SPA | 全量重载 | Vue SPA | 托管 SPA |
|
||||
| **版本管理** | **原生** | 原生(复杂) | mike 插件 | 手动 | 原生 |
|
||||
| **i18n** | **原生** | 原生(复杂) | 插件方式 | 原生 | 原生 |
|
||||
| **多项目** | **原生** | 插件 | 插件 | - | - |
|
||||
| **搜索** | **内置** | Algolia(云) | 内置 | MiniSearch | 云 |
|
||||
| **AI 上下文 (`llms.txt`)** | **内置** | - | - | - | 内置 |
|
||||
| **MCP 服务器** | **内置** | - | - | - | 内置 |
|
||||
| **Agent Skills** | **内置** | - | - | - | - |
|
||||
| **Docker 镜像** | **官方** | - | 官方 | - | - |
|
||||
| **自托管** | **可** | 可 | 可 | 可 | - |
|
||||
| **费用** | **免费 (OSS)** | 免费 (OSS) | 免费 (OSS) | 免费 (OSS) | Freemium |
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 零配置,即时启动
|
||||
把 docmd 指向任意 Markdown 目录,它就会运行。导航会根据你的目录结构自动生成。你可以在不到一分钟的时间内写好第一篇文档并上线 —— 没有模板代码,没有需要配置的构建流程,也不需要提前做技术决策。
|
||||
|
||||
### 默认轻量,处处高速
|
||||
默认的 JavaScript 体积约 18 kb,页面以即时 SPA 的方式切换。输出为静态 HTML —— 已做好 SEO 优化,包含 sitemap、canonical URL 与 Open Graph 元数据。内置离线全文搜索,无需任何云服务。
|
||||
|
||||
### AI 原生
|
||||
docmd 的设计贴合文档在当今被阅读与使用的方式:
|
||||
- **MCP 服务器** — `docmd mcp` 通过 stdio 把你的文档暴露给 AI Agent,让它们可以直接搜索、阅读并校验内容。
|
||||
- **上下文 (`llms.txt` / `llms-full.txt`)** — 在构建时生成完整的文档上下文,可被任何 LLM 立即消费。
|
||||
- **Agent Skills** — 面向 LLM 与 IDE Agent 的模块化指令集合([docmd-skills](https://github.com/docmd-io/docmd-skills))。
|
||||
- **复制为 Markdown / 复制上下文** — 浏览器内一键按钮,专门为粘贴到 AI 对话中做了优化。
|
||||
|
||||
### 为规模化而生
|
||||
- 通过多语言构建实现国际化
|
||||
- 支持多个文档版本的版本管理
|
||||
- 面向 monorepo 与多项目场景的 Workspaces
|
||||
- 用于扩展核心行为的插件体系
|
||||
- 完整的 Theming 支持、内置模板、自定义 CSS/JS,以及浅色 / 深色模式
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
docmd dev # 本地开发服务器
|
||||
docmd build # 构建用于部署
|
||||
docmd live # 浏览器端在线编辑器
|
||||
docmd init # 在当前目录生成新的 docmd.config.json
|
||||
docmd stop # 停止正在运行的 `docmd dev` / `docmd live` 服务器
|
||||
docmd doctor # 预检查: 配置 + 插件安装状态
|
||||
docmd migrate # 从 Docusaurus / VitePress / MkDocs / Starlight 导入
|
||||
docmd deploy # 生成 Docker / NGINX / Caddy / Vercel / Netlify 配置
|
||||
docmd validate # 检查全部内部链接
|
||||
docmd mcp # 以 MCP 服务器方式在 stdio 上运行
|
||||
docmd add <name> # 安装插件或模板
|
||||
```
|
||||
|
||||
## 插件
|
||||
|
||||
核心功能由一套稳健的插件系统驱动。基础能力默认已包含,特定需求可加装可选插件。
|
||||
|
||||
| 插件 | 状态 | 描述 |
|
||||
| :--- | :---: | :--- |
|
||||
| `search` | 核心 | 带模糊匹配的离线全文搜索 |
|
||||
| `seo` | 核心 | SEO 标签与 Open Graph 元数据 |
|
||||
| `sitemap` | 核心 | 生成 `sitemap.xml` |
|
||||
| `git` | 核心 | Git 提交历史与最后更新时间 |
|
||||
| `analytics` | 核心 | 轻量级分析集成 |
|
||||
| `llms` | 核心 | AI 上下文生成(`llms.txt` / `llms-full.txt`) |
|
||||
| `mermaid` | 核心 | Mermaid 图表支持 |
|
||||
| `openapi` | 核心 | 构建期 OpenAPI 3.x 规范渲染器 |
|
||||
| `okf` | Core | 面向 AI 代理的 Open Knowledge Format 包 (按 locale) |
|
||||
| `pwa` | 可选 | Progressive Web App —— 离线导航 |
|
||||
| `threads` | 可选 | 内联讨论串 *(by @svallory)* |
|
||||
| `math` | 可选 | KaTeX / LaTeX 数学公式渲染 |
|
||||
|
||||
安装可选插件:
|
||||
|
||||
```bash
|
||||
docmd add <plugin-name>
|
||||
```
|
||||
|
||||
开发你自己的插件:[插件开发指南](https://docs.docmd.io/development/building-plugins/)
|
||||
|
||||
## 配置
|
||||
|
||||
上手无需任何配置。仅在需要更多控制时,在项目根目录添加 `docmd.config.json`(或 `.ts` / `.js`):
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "我的项目",
|
||||
"url": "https://docs.myproject.com",
|
||||
"src": "./docs",
|
||||
"out": "./dist"
|
||||
}
|
||||
```
|
||||
|
||||
TypeScript 与 JavaScript 格式的配置文件支持动态值。
|
||||
|
||||
完整参考:[配置概览](https://docs.docmd.io/configuration/overview)
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
my-docs/
|
||||
├── docs/ ← 你的 Markdown 文件
|
||||
├── assets/ ← 图片与静态资源
|
||||
├── docmd.config.json ← 可选配置
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## 在线编辑器
|
||||
|
||||
基于浏览器的编辑器,所见即所得地撰写并预览文档 —— 无需任何本地配置。
|
||||
|
||||
<p>
|
||||
<img alt="docmd 在线编辑器预览" width="820" src="https://docs.docmd.io/assets/previews/live-editor-preview.webp">
|
||||
</p>
|
||||
|
||||
**前往 [live.docmd.io](https://live.docmd.io) 体验**
|
||||
|
||||
## 编程式 API
|
||||
|
||||
在 Node.js 脚本、CI 流水线或自定义构建步骤中使用 docmd。(同时支持 CommonJS 与 ESM。)
|
||||
|
||||
```javascript
|
||||
import { build } from '@docmd/core';
|
||||
|
||||
await build('./docmd.config.json', { isDev: false });
|
||||
```
|
||||
|
||||
完整参考:[Node API](https://docs.docmd.io/development/node-api-reference/)
|
||||
|
||||
## 社区
|
||||
|
||||
- **Bug 与问题** → [GitHub Issues](https://github.com/docmd-io/docmd/issues)
|
||||
- **问题与想法** → [Discussions](https://github.com/orgs/docmd-io/discussions)
|
||||
- **参与贡献** → [CONTRIBUTING.md](.github/CONTRIBUTING.md)
|
||||
- **路线图** → [GitHub Discussions](https://github.com/orgs/docmd-io/discussions/2)
|
||||
|
||||
## 支持我们
|
||||
|
||||
- 让更多人知道 docmd 是支持其开发最直接的方式 —— 在 X 上 [分享给你的朋友](https://twitter.com/intent/tweet?url=https://github.com/docmd-io/docmd&text=docmd%20-%20几秒内从%20Markdown%20生成生产可用的文档站点。),或点个 Star。
|
||||
- 如果 docmd 节省了你的时间,[GitHub Sponsorship](https://github.com/sponsors/mgks) 是巨大的鼓励。
|
||||
- 有想法或发现 Bug?欢迎提 Issue 或 PR,欢迎贡献你自己的插件。
|
||||
|
||||
## 许可
|
||||
|
||||
MIT —— 详见 [`LICENSE`](./LICENSE)。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
@@ -0,0 +1,83 @@
|
||||
# --------------------------------------------------------------------
|
||||
# docmd : the minimalist, zero-config documentation generator.
|
||||
#
|
||||
# @package @docmd/core (and ecosystem)
|
||||
# @website https://docmd.io
|
||||
# @repository https://github.com/docmd-io/docmd
|
||||
# @license MIT
|
||||
# @copyright Copyright (c) 2025-present docmd.io
|
||||
#
|
||||
# [docmd-source] - Please do not remove this header.
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
# Dependencies
|
||||
node_modules
|
||||
packages/*/node_modules
|
||||
packages/plugins/*/node_modules
|
||||
packages/engines/*/node_modules
|
||||
|
||||
# Build outputs
|
||||
dist
|
||||
*.tsbuildinfo
|
||||
packages/*/dist
|
||||
packages/plugins/*/dist
|
||||
packages/engines/*/dist
|
||||
|
||||
# Generated sites
|
||||
site
|
||||
packages/*/site
|
||||
packages/plugins/*/site
|
||||
public
|
||||
|
||||
# Development files
|
||||
.git
|
||||
.gitignore
|
||||
.github
|
||||
.vscode
|
||||
.idea
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
# Test and playground files
|
||||
packages/_playground
|
||||
temp-*
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
|
||||
# Native build artifacts (will be downloaded at runtime)
|
||||
packages/engines/rust-binaries/native/target
|
||||
|
||||
# Documentation
|
||||
docs
|
||||
*.md
|
||||
!README.md
|
||||
!LICENSE
|
||||
|
||||
# CI/CD files
|
||||
.github
|
||||
.travis.yml
|
||||
.circleci
|
||||
|
||||
# Editor files
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Test files
|
||||
test
|
||||
tests
|
||||
__tests__
|
||||
coverage
|
||||
.nyc_output
|
||||
|
||||
# Misc
|
||||
*.tgz
|
||||
*.bak
|
||||
tmp
|
||||
temp
|
||||
@@ -0,0 +1,236 @@
|
||||
# Docker Image for docmd
|
||||
|
||||
Official Docker image for docmd - the minimalist, zero-config documentation generator.
|
||||
|
||||
## Quick Start
|
||||
|
||||
> The examples below use `:latest` so you can copy-paste and run them immediately.
|
||||
> **For production, CI, and any reproducible build**, replace `:latest` with the specific version you want — see [Available Tags](#available-tags).
|
||||
|
||||
### Pull the Image
|
||||
|
||||
```bash
|
||||
# Pull from GitHub Container Registry (GHCR)
|
||||
docker pull ghcr.io/docmd-io/docmd:latest
|
||||
```
|
||||
|
||||
### Run Demo Site
|
||||
|
||||
The image ships with a demo template in `/template`. When you run the container with no volume mount, the entrypoint copies `/template` into `/docs` on first start, so the demo site comes up immediately.
|
||||
|
||||
```bash
|
||||
# Start with built-in demo site — works out of the box
|
||||
docker run -p 3000:3000 ghcr.io/docmd-io/docmd:latest
|
||||
|
||||
# Visit http://localhost:3000
|
||||
```
|
||||
|
||||
### Initialize New Project
|
||||
|
||||
```bash
|
||||
# Create and initialize a new project
|
||||
mkdir my-docs && cd my-docs
|
||||
docker run -v $(pwd):/workspace ghcr.io/docmd-io/docmd:latest init
|
||||
|
||||
# Start dev server
|
||||
docker run -v $(pwd):/workspace -p 3000:3000 ghcr.io/docmd-io/docmd:latest dev
|
||||
```
|
||||
|
||||
### Use Existing Docs
|
||||
|
||||
Mounting your own docs into `/docs` always wins — the entrypoint only seeds the demo template when `/docs` is completely empty, so your content is never overwritten.
|
||||
|
||||
```bash
|
||||
# Mount your docs and start dev server
|
||||
docker run -v $(pwd)/docs:/docs -p 3000:3000 ghcr.io/docmd-io/docmd:latest
|
||||
|
||||
# Build static site
|
||||
docker run -v $(pwd)/docs:/docs -v $(pwd)/site:/site ghcr.io/docmd-io/docmd:latest build
|
||||
```
|
||||
|
||||
## Available Tags
|
||||
|
||||
The image is published with two tags per release:
|
||||
|
||||
| Tag | Description | When to use |
|
||||
|-----|-------------|-------------|
|
||||
| `latest` | Floating alias for the most recent stable release | Quick start, local exploration, throwaway CI |
|
||||
| `<X.Y.Z>` | Pinned stable release (substitute the version you want) | Production, CI/CD, anything that must be reproducible |
|
||||
|
||||
> **Pin a specific version in production.** The examples below use `:latest` so you can copy-paste and run them immediately. For any pipeline whose output must be reproducible (or whose contracts you don't want silently changing), replace `:latest` with the specific version you want (e.g. `0.8.7`). Check the [package versions page](https://github.com/orgs/docmd-io/packages/container/docmd/versions) for the full list.
|
||||
|
||||
## Multi-Platform Support
|
||||
|
||||
The image is built for multiple architectures:
|
||||
|
||||
- `linux/amd64` - Standard x86_64 servers
|
||||
- `linux/arm64` - ARM-based servers (AWS Graviton, Raspberry Pi, Apple Silicon)
|
||||
|
||||
Docker automatically pulls the correct image for your platform.
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```yaml
|
||||
# Replace `:latest` with a specific version (e.g. `0.8.7`) for reproducible
|
||||
# production builds. See the GitHub releases page for available versions.
|
||||
services:
|
||||
docmd:
|
||||
image: ghcr.io/docmd-io/docmd:latest
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./docs:/docs
|
||||
- ./site:/site
|
||||
# The dev server defaults to 127.0.0.1 (loopback). For LAN access from
|
||||
# other devices, set DOCMD_HOST=0.0.0.0 or pass --host 0.0.0.0 explicitly.
|
||||
command: dev
|
||||
```
|
||||
|
||||
### GitHub Actions CI/CD
|
||||
|
||||
```yaml
|
||||
name: Build Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build documentation
|
||||
# Pin to a specific version (replace `:latest`) for reproducible CI runs.
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v ${{ github.workspace }}/docs:/docs \
|
||||
-v ${{ github.workspace }}/site:/site \
|
||||
ghcr.io/docmd-io/docmd:latest \
|
||||
build
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./site
|
||||
```
|
||||
|
||||
### Kubernetes Deployment
|
||||
|
||||
```yaml
|
||||
# Replace `:latest` with a specific version (e.g. `0.8.7`) for reproducible
|
||||
# production deploys. Update the tag when you upgrade.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: docmd-server
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: docmd
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: docmd
|
||||
spec:
|
||||
containers:
|
||||
- name: docmd
|
||||
image: ghcr.io/docmd-io/docmd:latest
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
volumeMounts:
|
||||
- name: docs
|
||||
mountPath: /docs
|
||||
# The dev server defaults to 127.0.0.1. For LAN access, set
|
||||
# DOCMD_HOST=0.0.0.0 or pass --host 0.0.0.0 explicitly.
|
||||
command: ["docmd", "dev"]
|
||||
volumes:
|
||||
- name: docs
|
||||
configMap:
|
||||
name: docs-content
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `NODE_ENV` | Node environment | `production` |
|
||||
| `DOCMD_CONTAINER` | Container mode flag | `true` |
|
||||
|
||||
## Building Locally
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/docmd-io/docmd.git
|
||||
cd docmd
|
||||
|
||||
# Build the image
|
||||
docker build -t docmd:local -f docker/Dockerfile .
|
||||
|
||||
# Test the build
|
||||
docker run --rm -v $(pwd)/docs:/docs docmd:local --version
|
||||
```
|
||||
|
||||
## Security Features
|
||||
|
||||
- Runs as non-root user (`docmd`)
|
||||
- Minimal Alpine Linux base image
|
||||
- Multi-stage build reduces attack surface
|
||||
- SBOM (Software Bill of Materials) included
|
||||
- OCI provenance attestation
|
||||
|
||||
## Health Check
|
||||
|
||||
The image includes a health check that verifies the dev server is running:
|
||||
|
||||
```bash
|
||||
# Check container health
|
||||
docker inspect --format='{{.State.Health.Status}}' <container-id>
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Permission Issues
|
||||
|
||||
The entrypoint automatically remaps to the uid:gid of the mounted directory, so files are always owned by the correct host user. If you hit permission issues, verify the mounted path exists and is writable on the host.
|
||||
|
||||
The dev server defaults to 127.0.0.1 (loopback only). To expose it to the LAN
|
||||
from inside a container, pass the host flag explicitly:
|
||||
|
||||
```bash
|
||||
# Loopback only (default) — access via http://localhost:3000 from the host
|
||||
docker run -p 3000:3000 ghcr.io/docmd-io/docmd:latest dev
|
||||
|
||||
# LAN access — the dev server binds to 0.0.0.0 inside the container, and
|
||||
# the port mapping makes it reachable from other devices on the host's network.
|
||||
docker run -p 3000:3000 ghcr.io/docmd-io/docmd:latest dev --host 0.0.0.0
|
||||
```
|
||||
|
||||
> **Security**: when you bind to 0.0.0.0, every host that can reach the
|
||||
> container's port can connect. Only do this on trusted networks. The dev
|
||||
> server's WebSocket requires an Origin that matches the bind host
|
||||
> (defence against CSWSH, CWE-1385), but loopback-only is the safer default.nsure you're binding to 0.0.0.0
|
||||
docker run -p 3000:3000 ghcr.io/docmd-io/docmd:latest dev --host 0.0.0.0
|
||||
```
|
||||
|
||||
### Memory Issues
|
||||
|
||||
```bash
|
||||
# For large documentation sites
|
||||
docker run --memory=2g -v $(pwd)/docs:/docs ghcr.io/docmd-io/docmd:latest build
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](../LICENSE) for details.
|
||||
|
||||
## Support
|
||||
|
||||
- **Issues**: [GitHub Issues](https://github.com/docmd-io/docmd/issues)
|
||||
- **Documentation**: [docs.docmd.io](https://docs.docmd.io)
|
||||
- **Website**: [docmd.io](https://docmd.io)
|
||||
@@ -0,0 +1,185 @@
|
||||
# --------------------------------------------------------------------
|
||||
# docmd : the minimalist, zero-config documentation generator.
|
||||
#
|
||||
# @package @docmd/core (and ecosystem)
|
||||
# @website https://docmd.io
|
||||
# @repository https://github.com/docmd-io/docmd
|
||||
# @license MIT
|
||||
# @copyright Copyright (c) 2025-present docmd.io
|
||||
#
|
||||
# [docmd-source] - Please do not remove this header.
|
||||
# --------------------------------------------------------------------
|
||||
#
|
||||
# Multi-stage Dockerfile for docmd
|
||||
# Creates a minimal production image with docmd CLI pre-installed
|
||||
#
|
||||
# Usage:
|
||||
# Quick Start (with demo site):
|
||||
# docker run -p 3000:3000 docmd:latest
|
||||
#
|
||||
# Initialize new project in current directory:
|
||||
# docker run -v $(pwd):/workspace docmd:latest init
|
||||
#
|
||||
# Run dev server with your docs:
|
||||
# docker run -v $(pwd)/my-docs:/docs -p 3000:3000 docmd:latest dev
|
||||
#
|
||||
# Build static site:
|
||||
# docker run -v $(pwd)/docs:/docs -v $(pwd)/site:/site docmd:latest build
|
||||
#
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 1: Build stage - compiles TypeScript and prepares the application
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Install build dependencies for native modules
|
||||
RUN apk add --no-cache \
|
||||
python3 \
|
||||
make \
|
||||
g++ \
|
||||
git \
|
||||
curl
|
||||
|
||||
# Install pnpm directly — avoids corepack version resolution issues in CI.
|
||||
# Bump the pinned version here whenever the monorepo's packageManager field changes.
|
||||
RUN npm install -g pnpm@10.33.2
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy everything (excluding files via .dockerignore)
|
||||
COPY . .
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Stub out packages that can't build in Docker before running the full build:
|
||||
# - engine-rust-binaries: requires cargo; pre-built .node files are in the repo
|
||||
# - _playground: dev sandbox, not part of the production image
|
||||
RUN sh tools/docker-prebuild.sh
|
||||
|
||||
# Build all packages
|
||||
RUN pnpm -r run build
|
||||
|
||||
# Resolve workspace:* deps to real version numbers for standalone use
|
||||
RUN node tools/resolve-ws-deps.js
|
||||
|
||||
# Pack all packages into tarballs (mimics npm publish)
|
||||
RUN pnpm -r pack --pack-destination /tarballs
|
||||
|
||||
# Generate a package.json listing every external (non-@docmd) runtime dep of
|
||||
# @docmd/core's transitive tree. The production stage installs from this file
|
||||
# instead of a hand-rolled list in the Dockerfile, so adding a dep to any
|
||||
# @docmd/* package automatically propagates to the Docker image.
|
||||
RUN node tools/print-okf-runtime-deps.js --emit-pkg=/tarballs/runtime-deps.json
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 2: Production stage - minimal image with only runtime dependencies
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache \
|
||||
git \
|
||||
curl \
|
||||
bash \
|
||||
su-exec
|
||||
|
||||
# Create non-root user for security
|
||||
RUN addgroup -g 1001 -S docmd && \
|
||||
adduser -S -D -H -u 1001 -h /app -s /bin/bash -G docmd -g docmd docmd
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy packed tarballs from builder
|
||||
COPY --from=builder /tarballs /tarballs
|
||||
|
||||
# Install only @docmd/* packages (the transitive deps of @docmd/core).
|
||||
# We extract each docmd-*.tgz tarball into /usr/local/lib/node_modules/@docmd/<name>.
|
||||
# This bypasses npm/pnpm registry lookups (packages aren't published yet at
|
||||
# build time). Legacy wrappers (doc.md, @mgks/docmd) and the monorepo root
|
||||
# tarball are excluded by only matching the docmd-* prefix.
|
||||
RUN mkdir -p /usr/local/lib/node_modules && \
|
||||
for tarball in /tarballs/docmd-*.tgz; do \
|
||||
fname=$(basename "$tarball" .tgz) && \
|
||||
pkg="${fname%-*}" && \
|
||||
scoped="@docmd/${pkg#docmd-}" && \
|
||||
mkdir -p "/usr/local/lib/node_modules/$scoped" && \
|
||||
tar -xzf "$tarball" -C "/usr/local/lib/node_modules/$scoped" --strip-components=1; \
|
||||
done
|
||||
|
||||
# Install external deps from the auto-generated runtime-deps.json produced by
|
||||
# the builder stage. We extract the dep list with node and pass it straight to
|
||||
# `npm install -g`, which puts packages into /usr/local/lib/node_modules —
|
||||
# exactly where Node's global resolution path looks. No hand-rolled list in the
|
||||
# Dockerfile: adding a dep to any @docmd/* package propagates automatically on
|
||||
# the next build because the builder stage regenerates runtime-deps.json fresh.
|
||||
RUN node -e "\
|
||||
const p=JSON.parse(require('fs').readFileSync('/tarballs/runtime-deps.json','utf8')); \
|
||||
const args=Object.entries(p.dependencies).map(([k,v])=>k+'@'+v); \
|
||||
const {execFileSync}=require('child_process'); \
|
||||
execFileSync('npm',['install','-g','--omit=dev','--no-audit','--no-fund',...args],{stdio:'inherit'}); \
|
||||
"
|
||||
|
||||
# Symlink the docmd CLI onto PATH (chmod +x on the script too — some
|
||||
# base images strip the executable bit during COPY).
|
||||
RUN ln -sf /usr/local/lib/node_modules/@docmd/core/dist/bin/docmd.js /usr/local/bin/docmd \
|
||||
&& chmod +x /usr/local/bin/docmd /usr/local/lib/node_modules/@docmd/core/dist/bin/docmd.js
|
||||
|
||||
# Clean up tarballs to reduce image size
|
||||
RUN rm -rf /tarballs
|
||||
|
||||
# Create directories for user content
|
||||
RUN mkdir -p /docs /site && chown -R docmd:docmd /docs /site
|
||||
|
||||
# Seed the default template by running docmd init non-interactively.
|
||||
# This ensures the image ships exactly what a real `docmd init` produces.
|
||||
#
|
||||
# Build does NOT fail on init failure: the image still publishes (so users
|
||||
# who mount their own /docs are unaffected), but the demo experience is
|
||||
# degraded and a clear warning is printed. Full stderr from `docmd init`
|
||||
# is in the build log above this block for diagnosis.
|
||||
RUN mkdir -p /template && \
|
||||
cd /template && \
|
||||
if docmd init --yes; then \
|
||||
if [ ! -f /template/docmd.config.json ]; then \
|
||||
printf '\n[docmd-docker] WARNING: docmd init exited 0 but /template/docmd.config.json is missing.\n' >&2; \
|
||||
printf '[docmd-docker] Demo seeding will not work. Mount your own /docs.\n' >&2; \
|
||||
fi; \
|
||||
else \
|
||||
printf '\n[docmd-docker] WARNING: docmd init failed during image build.\n' >&2; \
|
||||
printf '[docmd-docker] The `docker run` demo experience will not work. Mount your own /docs.\n' >&2; \
|
||||
printf '[docmd-docker] Full error from docmd init is in the build log above this block.\n' >&2; \
|
||||
fi
|
||||
|
||||
# Install entrypoint + healthcheck scripts (see docker/docker-entrypoint.sh
|
||||
# and docker/docker-healthcheck.sh). The entrypoint seeds /docs from
|
||||
# /template when /docs is empty, so `docker run` with no volume mount
|
||||
# still works out of the box. The healthcheck probes a port range because
|
||||
# `docmd dev` may auto-increment past 3000 if the port is already taken.
|
||||
COPY docker/docker-entrypoint.sh /usr/local/bin/docmd-entrypoint.sh
|
||||
COPY docker/docker-healthcheck.sh /usr/local/bin/docmd-healthcheck.sh
|
||||
RUN chmod +x /usr/local/bin/docmd-entrypoint.sh /usr/local/bin/docmd-healthcheck.sh
|
||||
|
||||
# Set environment variables
|
||||
ENV NODE_ENV=production
|
||||
ENV DOCMD_CONTAINER=true
|
||||
|
||||
# Run as root so the entrypoint can inspect /docs ownership and use
|
||||
# su-exec to re-exec as the correct uid:gid. The entrypoint never stays
|
||||
# as root — it always drops to the detected uid before exec-ing docmd.
|
||||
# This is the same pattern used by postgres, redis, and other official images.
|
||||
|
||||
# Set working directory to docs (where user content will be mounted)
|
||||
WORKDIR /docs
|
||||
|
||||
# Expose default port (dev server may pick a higher one if 3000 is in use)
|
||||
EXPOSE 3000
|
||||
|
||||
# Health check — probes 3000-3005 to follow the dev server's actual port
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 CMD ["/usr/local/bin/docmd-healthcheck.sh"]
|
||||
|
||||
# Default command — entrypoint detects /docs uid, drops privileges, then
|
||||
# seeds /docs if empty and execs the supplied command.
|
||||
ENTRYPOINT ["/usr/local/bin/docmd-entrypoint.sh"]
|
||||
CMD ["dev", "--host", "0.0.0.0"]
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/bin/sh
|
||||
# docmd Docker entrypoint
|
||||
#
|
||||
# Handles two cases:
|
||||
#
|
||||
# 1. No volume / empty /docs → seeds the demo template so `docmd dev`
|
||||
# works out of the box with no arguments.
|
||||
#
|
||||
# 2. Host-mounted volume → detects the uid:gid that owns /docs and
|
||||
# re-execs as that identity (via su-exec), so file writes from any
|
||||
# subcommand — including `docmd init` — land with the correct host
|
||||
# ownership without needing `-u $(id -u):$(id -g)` from the caller.
|
||||
#
|
||||
# This mirrors the pattern used by official images (postgres, redis, etc.).
|
||||
set -e
|
||||
|
||||
FIRST_ARG="${1:-}"
|
||||
|
||||
# ── uid remapping ────────────────────────────────────────────────────────────
|
||||
# If we are currently running as root (the entrypoint is exec'd as root so it
|
||||
# can inspect /docs ownership), detect the uid:gid of /docs and re-exec as
|
||||
# that user. This lets `docmd init` and `docmd dev` write to host-mounted
|
||||
# volumes without permission errors regardless of the host user's uid.
|
||||
if [ "$(id -u)" = "0" ] && [ -d /docs ]; then
|
||||
DOCS_UID=$(stat -c '%u' /docs 2>/dev/null || echo "1001")
|
||||
DOCS_GID=$(stat -c '%g' /docs 2>/dev/null || echo "1001")
|
||||
|
||||
# If /docs is owned by root (uid 0) on the host, fall back to the docmd
|
||||
# user — writing as root inside the container is a security anti-pattern.
|
||||
if [ "$DOCS_UID" = "0" ]; then
|
||||
DOCS_UID="1001"
|
||||
DOCS_GID="1001"
|
||||
fi
|
||||
|
||||
# ── writability check (N-18) ───────────────────────────────────────────
|
||||
# Catch read-only volume mounts early with a clear message instead of
|
||||
# letting docmd crash mid-build with a cryptic EROFS / EACCES error.
|
||||
# Only applies to write commands — `dev`, `build`, `init`.
|
||||
# Read-only mounts are valid for `validate`, `mcp`, etc.
|
||||
case "$FIRST_ARG" in
|
||||
dev|build|init)
|
||||
if ! touch /docs/.docmd_write_test 2>/dev/null; then
|
||||
printf '\n[docmd] ERROR: /docs is read-only (or not writable by uid %s).\n' "$DOCS_UID" >&2
|
||||
printf '[docmd] Mount a writable directory:\n' >&2
|
||||
printf '[docmd] docker run -v $(pwd)/docs:/docs ghcr.io/docmd-io/docmd\n' >&2
|
||||
printf '[docmd] Or check directory permissions on the host.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -f /docs/.docmd_write_test
|
||||
;;
|
||||
esac
|
||||
|
||||
# Seed demo template before dropping privileges (root can always write).
|
||||
if [ "$FIRST_ARG" != "init" ] && \
|
||||
[ -z "$(ls -A /docs 2>/dev/null)" ] && \
|
||||
[ -d /template ]; then
|
||||
echo "[docmd] /docs is empty — seeding demo template from /template"
|
||||
cp -r /template/. /docs/
|
||||
chown -R "$DOCS_UID:$DOCS_GID" /docs 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Re-exec as the detected uid:gid so all subsequent writes are correctly owned.
|
||||
exec su-exec "$DOCS_UID:$DOCS_GID" "$0" "$@"
|
||||
fi
|
||||
|
||||
# ── already the right user (re-exec path lands here) ────────────────────────
|
||||
exec docmd "$@"
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
# docmd Docker healthcheck
|
||||
#
|
||||
# The dev server may auto-increment its port (3000, 3001, ...) if the
|
||||
# default port is in use, so we probe a small range instead of hard-
|
||||
# coding a single port. Returns 0 as soon as any port responds.
|
||||
for port in 3000 3001 3002 3003 3004 3005; do
|
||||
if curl -fs --connect-timeout 2 "http://127.0.0.1:${port}/" >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
exit 1
|
||||
@@ -0,0 +1,16 @@
|
||||
// eslint-rules/index.js — docmd internal ESLint rules.
|
||||
// Registered in eslint.config.mjs as plugin "docmd".
|
||||
|
||||
import noUnsafeFsRead from './no-unsafe-fs-read.js';
|
||||
import requireVerifyClient from './require-verify-client.js';
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
name: 'docmd-internal-rules',
|
||||
version: '0.0.1'
|
||||
},
|
||||
rules: {
|
||||
'no-unsafe-fs-read': noUnsafeFsRead,
|
||||
'require-verify-client': requireVerifyClient
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
// eslint-rules/no-unsafe-fs-read.js
|
||||
// Flags fs.* file calls whose path argument was not resolved through
|
||||
// safePath(root, ...). See DEVELOPMENT-BENCHMARK.md S1 (CWE-22).
|
||||
|
||||
const TARGET_METHODS = new Set([
|
||||
'readFile', 'readFileSync',
|
||||
'writeFile', 'writeFileSync',
|
||||
'appendFile', 'appendFileSync',
|
||||
'unlink', 'unlinkSync',
|
||||
'stat', 'statSync',
|
||||
'lstat', 'lstatSync',
|
||||
'readdir', 'readdirSync',
|
||||
'mkdir', 'mkdirSync',
|
||||
'rmdir', 'rmdirSync',
|
||||
'access', 'accessSync'
|
||||
]);
|
||||
|
||||
const FS_ROOT_NAMES = new Set(['fs', 'fsPromises', 'nodeFs', 'nodefs', 'fsp']);
|
||||
|
||||
function getFsMethod(node) {
|
||||
if (node.type !== 'CallExpression') return null;
|
||||
const callee = node.callee;
|
||||
if (callee.type !== 'MemberExpression' || callee.computed) return null;
|
||||
const prop = callee.property;
|
||||
if (prop.type !== 'Identifier') return null;
|
||||
if (!TARGET_METHODS.has(prop.name)) return null;
|
||||
// Only flag when the root object of the call chain is a known fs import.
|
||||
// Wrappers like ctx.readFile(...) and sourceTools.readFile(...) are NOT flagged
|
||||
// because they typically perform their own safePath() validation internally.
|
||||
let obj = callee.object;
|
||||
while (obj && obj.type === 'MemberExpression' && !obj.computed) {
|
||||
obj = obj.object;
|
||||
}
|
||||
if (!obj || obj.type !== 'Identifier') return null;
|
||||
if (!FS_ROOT_NAMES.has(obj.name)) return null;
|
||||
return prop.name;
|
||||
}
|
||||
|
||||
function isSafeExpr(node, safeNames) {
|
||||
if (!node) return false;
|
||||
if (node.type === 'CallExpression' &&
|
||||
node.callee.type === 'Identifier' &&
|
||||
node.callee.name === 'safePath') {
|
||||
return true;
|
||||
}
|
||||
if (node.type === 'Identifier') {
|
||||
return safeNames.has(node.name);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Disallow fs.* file calls whose path argument was not resolved through safePath(root, ...).'
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
unsafe: 'fs.{{ method }}() path argument must be resolved via safePath(projectRoot, userPath). CWE-22 path traversal prevention — see DEVELOPMENT-BENCHMARK.md S1.'
|
||||
}
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const safeNames = new Set();
|
||||
|
||||
function isSafePathCall(node) {
|
||||
return node &&
|
||||
node.type === 'CallExpression' &&
|
||||
node.callee.type === 'Identifier' &&
|
||||
node.callee.name === 'safePath';
|
||||
}
|
||||
|
||||
function trackSafePathReturn(node) {
|
||||
if (!node.init) return;
|
||||
if (node.id.type !== 'Identifier') return;
|
||||
if (isSafePathCall(node.init)) {
|
||||
safeNames.add(node.id.name);
|
||||
}
|
||||
}
|
||||
|
||||
function trackAssignment(node) {
|
||||
// Pattern: x = safePath(...)
|
||||
if (node.left.type !== 'Identifier') return;
|
||||
if (node.operator !== '=') return;
|
||||
if (isSafePathCall(node.right)) {
|
||||
safeNames.add(node.left.name);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
VariableDeclarator: trackSafePathReturn,
|
||||
AssignmentExpression: trackAssignment,
|
||||
CallExpression(node) {
|
||||
const method = getFsMethod(node);
|
||||
if (!method) return;
|
||||
const firstArg = node.arguments[0];
|
||||
if (isSafeExpr(firstArg, safeNames)) return;
|
||||
context.report({ node, messageId: 'unsafe', data: { method } });
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
// eslint-rules/require-verify-client.js
|
||||
// Requires every new WebSocketServer({...}) to include a verifyClient
|
||||
// callback. See DEVELOPMENT-BENCHMARK.md S3 (CWE-1385).
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Require WebSocketServer({...}) to include a verifyClient callback that validates the Origin header.'
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
missing: 'new WebSocketServer({...}) must include a verifyClient callback that validates the Origin header. CWE-1385 (CSWSH) prevention — see DEVELOPMENT-BENCHMARK.md S3.'
|
||||
}
|
||||
},
|
||||
|
||||
create(context) {
|
||||
function hasVerifyClient(objExpr) {
|
||||
return objExpr.properties.some((p) => (
|
||||
p.type === 'Property' &&
|
||||
!p.computed &&
|
||||
p.key.type === 'Identifier' &&
|
||||
p.key.name === 'verifyClient'
|
||||
));
|
||||
}
|
||||
|
||||
return {
|
||||
NewExpression(node) {
|
||||
if (node.callee.type !== 'Identifier') return;
|
||||
if (node.callee.name !== 'WebSocketServer') return;
|
||||
if (node.arguments.length === 0) {
|
||||
context.report({ node, messageId: 'missing' });
|
||||
return;
|
||||
}
|
||||
const arg = node.arguments[0];
|
||||
if (arg.type !== 'ObjectExpression') return;
|
||||
if (!hasVerifyClient(arg)) {
|
||||
context.report({ node, messageId: 'missing' });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import eslint from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import docmdRules from './eslint-rules/index.js';
|
||||
|
||||
export default [
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
process: 'readonly',
|
||||
console: 'readonly',
|
||||
require: 'readonly',
|
||||
module: 'readonly',
|
||||
__dirname: 'readonly',
|
||||
__filename: 'readonly',
|
||||
setTimeout: 'readonly',
|
||||
clearTimeout: 'readonly',
|
||||
requestAnimationFrame: 'readonly',
|
||||
cancelAnimationFrame: 'readonly',
|
||||
Buffer: 'readonly',
|
||||
exports: 'writable',
|
||||
document: 'readonly',
|
||||
window: 'readonly',
|
||||
localStorage: 'readonly',
|
||||
IntersectionObserver: 'readonly',
|
||||
fetch: 'readonly',
|
||||
DOMParser: 'readonly',
|
||||
history: 'readonly',
|
||||
CustomEvent: 'readonly',
|
||||
Event: 'readonly',
|
||||
MouseEvent: 'readonly',
|
||||
KeyboardEvent: 'readonly',
|
||||
getComputedStyle: 'readonly',
|
||||
navigator: 'readonly',
|
||||
location: 'readonly',
|
||||
URL: 'readonly'
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'warn',
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'preserve-caught-error': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': 'warn',
|
||||
'no-useless-escape': 'off',
|
||||
'no-useless-assignment': 'off',
|
||||
'@typescript-eslint/no-unused-expressions': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: ['**/dist/**', '**/node_modules/**', '**/site/**', 'temp-*/**', '**/public/**']
|
||||
},
|
||||
{
|
||||
// docmd internal security rules — Phase 0 failsafe for DEVELOPMENT-BENCHMARK.md S1 and S3.
|
||||
// Promoted to 'error' after Phase 1 cleans up the surface area.
|
||||
plugins: { docmd: docmdRules },
|
||||
rules: {
|
||||
'docmd/no-unsafe-fs-read': 'warn',
|
||||
'docmd/require-verify-client': 'error'
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"name": "@docmd/monorepo",
|
||||
"version": "0.8.12",
|
||||
"private": true,
|
||||
"description": "The minimalist, zero-config documentation generator monorepo.",
|
||||
"scripts": {
|
||||
"dev": "pnpm docmd dev",
|
||||
"live": "pnpm docmd-live",
|
||||
"build": "pnpm -r run build",
|
||||
"doctor": "pnpm docmd doctor",
|
||||
"validate": "pnpm docmd validate",
|
||||
"migrate": "pnpm docmd migrate",
|
||||
"gen:deploy": "pnpm docmd deploy",
|
||||
"mcp": "pnpm docmd mcp",
|
||||
"plugin:add": "pnpm docmd add",
|
||||
"plugin:remove": "pnpm docmd remove",
|
||||
"docmd": "node packages/core/dist/bin/docmd.js --cwd packages/_playground",
|
||||
"docmd-live": "node packages/live/bin/docmd-live.js --cwd packages/_playground",
|
||||
"prep": "node tools/prep.js",
|
||||
"verify": "node tools/verify.js",
|
||||
"lint": "eslint .",
|
||||
"bump": "node tools/bump.js",
|
||||
"docker:build": "docker build -t docmd:latest -f docker/Dockerfile .",
|
||||
"docker:build:version": "node -e \"const v=require('./package.json').version; require('child_process').execSync('docker build -t docmd:' + v + ' -t docmd:latest -f docker/Dockerfile .', {stdio:'inherit'})\"",
|
||||
"docker:test": "docker run --rm -v $(pwd)/docs:/docs docmd:latest --version",
|
||||
"docker:run": "docker run -p 3000:3000 docmd:latest",
|
||||
"stop": "node packages/core/dist/bin/docmd.js stop >/dev/null 2>&1 || true",
|
||||
"clean": "rm -rf node_modules packages/*/node_modules packages/plugins/*/node_modules packages/engines/*/node_modules packages/*/dist packages/plugins/*/dist packages/engines/*/dist packages/engines/rust/native/bin/*.node packages/*/public packages/plugins/*/public packages/*/site packages/plugins/*/site *.tsbuildinfo temp-* >/dev/null 2>&1;",
|
||||
"unlink": "npm uninstall -g @docmd/core @docmd/monorepo docmd docmd-live -s >/dev/null 2>&1 || true; pnpm uninstall -g @docmd/core @docmd/monorepo docmd docmd-live -s >/dev/null 2>&1 || true; rm -f $(which -a docmd 2>/dev/null) $(which -a docmd-live 2>/dev/null) >/dev/null 2>&1 || true;",
|
||||
"reset": "node tools/status.js start:reset && pnpm -s stop && pnpm -s unlink && pnpm -s clean && node tools/status.js reset"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^24.13.3",
|
||||
"chalk": "^4.1.2",
|
||||
"docmd-search": "^0.1.0",
|
||||
"esbuild": "^0.28.1",
|
||||
"eslint": "^10.6.0",
|
||||
"globals": "^17.7.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.63.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"packageManager": "pnpm@10.33.2",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild"
|
||||
],
|
||||
"overrides": {},
|
||||
"peerDependencyRules": {
|
||||
"ignoreMissing": [
|
||||
"docmd-search"
|
||||
]
|
||||
}
|
||||
},
|
||||
"keywords": [
|
||||
"docmd",
|
||||
"markdown",
|
||||
"minimalist",
|
||||
"zero-config",
|
||||
"site-generator",
|
||||
"static-site-generator",
|
||||
"documentation",
|
||||
"typescript",
|
||||
"javascript",
|
||||
"nodejs",
|
||||
"html",
|
||||
"browser",
|
||||
"website",
|
||||
"generator",
|
||||
"parser",
|
||||
"hosted",
|
||||
"cli",
|
||||
"ssg",
|
||||
"docs"
|
||||
],
|
||||
"author": {
|
||||
"name": "Ghazi",
|
||||
"url": "https://mgks.dev"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/docmd-io/docmd.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/docmd-io/docmd/issues"
|
||||
},
|
||||
"homepage": "https://docmd.io",
|
||||
"funding": "https://github.com/sponsors/mgks",
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"testuser": {
|
||||
"name": "TestUser",
|
||||
"avatarUrl": "https://gravatar.com/avatar/3d5ff960d1bb8dd8c99ef041d5ed5f21?s=80&d=mp"
|
||||
},
|
||||
"ghazi": {
|
||||
"name": "Ghazi",
|
||||
"avatarUrl": "https://gravatar.com/avatar/3d5ff960d1bb8dd8c99ef041d5ed5f21?s=80&d=mp"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"engine": "rust",
|
||||
"title": "_playground",
|
||||
"favicon": "assets/favicon.ico",
|
||||
"theme": {
|
||||
"name": "summer",
|
||||
"customCss": []
|
||||
},
|
||||
"layout": {
|
||||
"menubar": {
|
||||
"enabled": true,
|
||||
"position": "header",
|
||||
"left": [ { "text": "Menu 1", "url": "/" }, { "text": "Menu 2", "url": "/nostyle" } ]
|
||||
},
|
||||
"optionsMenu": {
|
||||
"position": "menubar",
|
||||
"components": {
|
||||
"search": true,
|
||||
"themeSwitch": true,
|
||||
"sponsor": "https://github.com/sponsors/mgks"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"style": "minimal"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"search": {
|
||||
"semantic": true,
|
||||
"showConfidence": true
|
||||
},
|
||||
"seo": {},
|
||||
"sitemap": {},
|
||||
"analytics": {
|
||||
"googleV4": { "measurementId": "G-X9WTDL262N" }
|
||||
},
|
||||
"llms": {},
|
||||
"mermaid": {},
|
||||
"git": {
|
||||
"repo": "https://github.com/docmd-io/docmd"
|
||||
},
|
||||
"openapi": {},
|
||||
"math": {},
|
||||
"okf": {
|
||||
"graph": true
|
||||
}
|
||||
},
|
||||
|
||||
"versions": {
|
||||
"current": "06",
|
||||
"position": "sidebar-top",
|
||||
"all": [
|
||||
{ "id": "06", "dir": "docs", "label": "v0.6.0" },
|
||||
{ "id": "05", "dir": "docs-05", "label": "v0.5.0",
|
||||
"navigation": [
|
||||
{ "title": "V0.5 Home", "path": "/", "icon": "home" },
|
||||
{ "title": "V0.5 Install", "path": "/getting-started/installation", "icon": "download" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"i18n": {
|
||||
"default": "en",
|
||||
"locales": [
|
||||
{ "id": "en", "label": "English", "dir": "ltr" },
|
||||
{ "id": "hi", "label": "हिन्दी", "dir": "ltr" },
|
||||
{ "id": "zh", "label": "中文", "dir": "ltr" },
|
||||
{ "id": "es", "label": "Español", "dir": "ltr" }
|
||||
]
|
||||
},
|
||||
|
||||
"navigation": [
|
||||
{
|
||||
"title": "Getting Started",
|
||||
"children": [
|
||||
{ "title": "Latest Home", "path": "/", "icon": "home" },
|
||||
{
|
||||
"title": "Mermaid icon test",
|
||||
"path": "/mermaid-icon-test",
|
||||
"icon": "mermaid",
|
||||
"children": [
|
||||
{ "title": "Nested Page 1", "path": "/nested-page-1" },
|
||||
{ "title": "Nested Page 2", "path": "/nested-page-2" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# V0.5.1 Installation
|
||||
@@ -0,0 +1,34 @@
|
||||
# V0.5.0 Overview
|
||||
|
||||
This is the documentation for version 0.5.0 of docmd. This version introduces several new features for developers.
|
||||
|
||||
## Features
|
||||
|
||||
- **Developer Tools**: Enhanced debugging capabilities
|
||||
- **Performance**: Faster build times for large documentation sites
|
||||
- **Search**: Improved keyword search with better relevance scoring
|
||||
- **Plugins**: New plugin API for extending functionality
|
||||
|
||||
## Getting Started
|
||||
|
||||
To get started with version 0.5.0, install the package:
|
||||
|
||||
```bash
|
||||
npm install @docmd/core@0.5.0
|
||||
```
|
||||
|
||||
## Developer Guide
|
||||
|
||||
This version is designed with developers in mind. The new developer tools make it easier to debug and customize your documentation site.
|
||||
|
||||
### Configuration
|
||||
|
||||
Configure your site by creating a `docmd.config.js` file in your project root.
|
||||
|
||||
### Building
|
||||
|
||||
Run the build command to generate your documentation:
|
||||
|
||||
```bash
|
||||
npx docmd build
|
||||
```
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ghazi": {
|
||||
"name": "Ghazi",
|
||||
"avatarUrl": "https://gravatar.com/avatar/3d5ff960d1bb8dd8c99ef041d5ed5f21?s=80&d=mp"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
title: "docmd _playground"
|
||||
description: "A testing ground for docmd core engine features."
|
||||
---
|
||||
|
||||
This playground is used to verify core engine changes in real-time. Use this to ensure your modifications to the Markdown parser or UI components behave as expected.
|
||||
|
||||
::: callout tip "How to work here"
|
||||
To test your changes, keep the dev server running (`pnpm run dev`). Any change you make to `packages/core` or `packages/parser` will trigger a hot-reload here instantly.
|
||||
:::
|
||||
|
||||
# H1 Test Heading
|
||||
|
||||
This is a test H1 heading to verify it appears in the TOC and has proper anchor links.
|
||||
|
||||
### Component Verification
|
||||
|
||||
Test your UI components and parser rules here to ensure consistency:
|
||||
|
||||
::: card "Container Test"
|
||||
Test nested callouts and containers.
|
||||
|
||||
::: callout warning "Warning"
|
||||
Ensure nested items render correctly.
|
||||
:::
|
||||
:::
|
||||
|
||||
::: tabs
|
||||
== tab "Feature A"
|
||||
#### Feature A
|
||||
Verification content.
|
||||
== tab "Feature B"
|
||||
#### Feature B
|
||||
Verification content.
|
||||
:::
|
||||
|
||||
### 🔗 Useful Links
|
||||
|
||||
- [Official Documentation](https://docs.docmd.io)
|
||||
- [GitHub Repository](https://github.com/docmd-io/docmd)
|
||||
- [Report an Issue](https://github.com/docmd-io/docmd/issues)
|
||||
|
||||
### 🧪 Developer Checklist
|
||||
- [ ] **Parser:** Does the Markdown output match the HTML in `packages/parser/src/html-renderer.js`?
|
||||
- [ ] **UI:** Does the theme CSS apply to this page correctly?
|
||||
- [ ] **SPA:** Does navigation between pages work without a hard refresh?
|
||||
|
||||
### 🧜♀️ Mermaid Interactive Test
|
||||
|
||||
Test the new interactive controls (Pan, Zoom, Fullscreen) and Lucide icon support here:
|
||||
|
||||
#### Complex Flowchart
|
||||
```mermaid
|
||||
graph TD
|
||||
Start((Start)) --> Process1[Process One]
|
||||
Process1 --> Decision{Is it valid?}
|
||||
Decision -- Yes --> Process2[Process Two]
|
||||
Decision -- No --> Error[Error Handling]
|
||||
Process2 --> Subgraph1
|
||||
subgraph Subgraph1 [Critical Path]
|
||||
Step1[Step 1] --> Step2[Step 2]
|
||||
end
|
||||
Step2 --> End((End))
|
||||
Error --> End
|
||||
```
|
||||
|
||||
#### Architecture Diagram (Lucide Icons)
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Trigger: aaaa] --> B[take an action]
|
||||
B --> C[x]
|
||||
C --> D{yyy?}
|
||||
D -->|No| E[Return]
|
||||
D -->|Yes| F{zzz?}
|
||||
```
|
||||
|
||||
#### Code Block with Title
|
||||
```typescript "main.ts"
|
||||
function hello() {
|
||||
console.log("Hello from docmd!");
|
||||
}
|
||||
```
|
||||
|
||||
#### Math Plugin Test
|
||||
|
||||
$$
|
||||
\sum_{i=1}^n i^2 = \frac{n(n+1)(2n+1)}{6}
|
||||
$$
|
||||
|
||||
### 🪜 Steps Container Test
|
||||
|
||||
::: steps
|
||||
1. #### Step One: Initialization
|
||||
Install dependencies and configure your environment.
|
||||
2. #### Step Two: Configuration
|
||||
Modify `docmd.config.json` to suit your project needs.
|
||||
3. #### Step Three: Build
|
||||
Run `pnpm build` to compile the templates and documents.
|
||||
:::
|
||||
|
||||
### 📅 Changelog Container Test
|
||||
|
||||
::: changelog
|
||||
== v0.8.7 - "Refining Summer Theme"
|
||||
Refined the Summer theme Table of Contents thread, active ancestor branch indicators, and spacing of components.
|
||||
* Fixed search modal styling.
|
||||
* Added custom styling for steps, tabs, cards, and details containers.
|
||||
|
||||
== v0.8.0 - "Release of docmd 0.8"
|
||||
Initial preview of the zero-config documentation engine with monorepo workspace support.
|
||||
:::
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
title: "Mermaid Icon Test"
|
||||
---
|
||||
|
||||
# Mermaid Icon Test
|
||||
|
||||
This diagram tests the generic `icon:` prefix (which maps to the Lucide icon pack):
|
||||
|
||||
```mermaid
|
||||
architecture-beta
|
||||
group api(icon:cloud)[API]
|
||||
service db(icon:database)[Database] in api
|
||||
service disk(icon:hard-drive)[Storage] in api
|
||||
db:L -- R:disk
|
||||
```
|
||||
|
||||
Check if icons appear correctly.
|
||||
|
||||
|
||||
# Tag Test
|
||||
This is a ::: tag "v0.7.4" color:#f0f inline tag.
|
||||
And this is a ::: tag "With Icon" icon:star color:blue link:https://google.com inline tag.
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
title: Nested Page 1
|
||||
---
|
||||
|
||||
# Nested Page 1
|
||||
|
||||
This is the first nested page for verifying sidebar multi-level navigation.
|
||||
|
||||
::: steps
|
||||
|
||||
1. First step of verification.
|
||||
|
||||
2. Second step of verification.
|
||||
:::
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
title: Nested Page 2
|
||||
---
|
||||
|
||||
# Nested Page 2
|
||||
|
||||
This is the second nested page for verifying sidebar multi-level navigation.
|
||||
|
||||
::: callout info
|
||||
This callout tests the styling consistency within nested pages.
|
||||
:::
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
title: "Practical test index"
|
||||
description: "Test pages that exercise every Phase 1–3 fix and the OKF plugin in the actual _playground build."
|
||||
noindex: false
|
||||
---
|
||||
|
||||
# Practical test index
|
||||
|
||||
These pages are **deliberately constructed test fixtures** that
|
||||
exercise every fix shipped in 0.8.8. They live inside the
|
||||
`_playground` site (alongside the real docs) and are included in
|
||||
every build. Each page is annotated with a header block that
|
||||
names the test and the report section it covers.
|
||||
|
||||
The intent is that running `pnpm docmd build` from
|
||||
`packages/_playground/` produces an output where every Phase
|
||||
1–3 + OKF fix is observable in a real build, not just in unit
|
||||
tests.
|
||||
|
||||
## What each page tests
|
||||
|
||||
| Page | Phase | Fix |
|
||||
|---|---|---|
|
||||
| [`phase-2/f1-nested-grids`](./phase-2/f1-nested-grids) | 2 | F1 — `::: grids` + N× `::: grid` + 1 close per card |
|
||||
| [`phase-2/f2-self-closing-button`](./phase-2/f2-self-closing-button) | 2 | F2 — self-closing `::: button` + orphan `:::` |
|
||||
| [`phase-2/f3-mismatched-close`](./phase-2/f3-mismatched-close) | 2 | F3 — `::: callout` closed by `::: card` |
|
||||
| [`phase-2/f4-triple-close`](./phase-2/f4-triple-close) | 2 | F4 — triple `:::` after balanced callout |
|
||||
| [`phase-2/f5-five-level-nesting`](./phase-2/f5-five-level-nesting) | 2 | F5 — 5-level nested callouts render all 5 levels |
|
||||
| [`phase-3/okf-bundle-default`](./phase-3/okf-bundle-default) | 3 + OKF | OKF default-enabled, type inference, noindex opt-out |
|
||||
| [`phase-3/internal-link-with-trailing-slash`](./phase-3/internal-link-with-trailing-slash) | 3 | M-1 — `validate` accepts trailing-slash links |
|
||||
| [`phase-3/xss-in-frontmatter`](./phase-3/xss-in-frontmatter) | 1 | T-S3 / S-4 / S-5 — frontmatter XSS payloads escaped |
|
||||
|
||||
> Each page documents, in its own body, what it would render as
|
||||
> *before* the fix and what it renders as *after* the fix.
|
||||
|
||||
## How to verify
|
||||
|
||||
```bash
|
||||
cd packages/_playground
|
||||
node ../../packages/core/dist/bin/docmd.js build
|
||||
# OKF bundle at site/okf/ (default-enabled — no config needed)
|
||||
# Generated output in site/
|
||||
node ../../packages/core/dist/bin/docmd.js validate
|
||||
# exit 0 — internal-link-with-trailing-slash.md links to a real page
|
||||
node ../../packages/core/dist/bin/docmd.js validate --json
|
||||
# exit 0 (no errors) — JSON output unchanged
|
||||
```
|
||||
|
||||
After the build, the OKF bundle at `site/okf/` includes a
|
||||
`concepts/` directory containing one entry per page. Inspect
|
||||
`site/okf/okf.yaml` to see the typed manifest.
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
title: "Phase 2 — Container parser determinism across worker threads"
|
||||
description: "Demonstrates that the container normaliser is a pure function of its input. Two workers, 100 concurrent parses, all produce byte-identical HTML."
|
||||
okf:
|
||||
type: reference
|
||||
---
|
||||
|
||||
# Container parser determinism
|
||||
|
||||
This page documents the **determinism contract** that backs the
|
||||
Phase 2 parser. The container normaliser is a pure function of
|
||||
its input — no `Date.now()`, no `Math.random()`, no module-level
|
||||
mutable state. Two worker threads given the same source produce
|
||||
byte-identical HTML.
|
||||
|
||||
## How the determinism is enforced
|
||||
|
||||
Three layers, each guarding the next:
|
||||
|
||||
1. **Declarative** — `packages/parser/src/utils/container-normaliser.ts`
|
||||
has a `DETERMINISM AUDIT` block in the file header that
|
||||
documents the four non-deterministic primitives the module
|
||||
deliberately does NOT use (`Date.now()`, `Math.random()`,
|
||||
module-level `let`/`var`, `process.env` reads).
|
||||
|
||||
2. **Empirical** — `packages/parser/test/container-normaliser.test.js`
|
||||
has 60 assertions including a 100-way concurrent parse test
|
||||
AND a real `node:worker_threads` cross-worker parse. Both
|
||||
assert byte-identical output.
|
||||
|
||||
3. **Regression-proof** — `packages/core/src/engine/worker-parser.ts`
|
||||
has a `verifyDeterminismAtBoot()` function that runs at the
|
||||
end of `init()`. It parses a known input through the
|
||||
freshly-built processor and asserts the output equals a
|
||||
frozen snapshot. If a future code change introduces
|
||||
non-determinism, the worker throws at boot and the build
|
||||
fails immediately.
|
||||
|
||||
## The 100-way test
|
||||
|
||||
The test fixture in `container-normaliser.test.js`:
|
||||
|
||||
```js
|
||||
test('determinism: 100 concurrent parses of the same source produce identical HTML', async () => {
|
||||
const md = freshProcessor();
|
||||
const src = '<complex source with nested grids + cards>';
|
||||
const N = 100;
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: N }, () => processContentAsync(src, md, {}, { filePath: 'det.md' }))
|
||||
);
|
||||
const first = results[0].htmlContent;
|
||||
for (let i = 1; i < N; i++) {
|
||||
assert.equal(results[i].htmlContent, first, `divergence at index ${i}`);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
100 parallel parses, byte-identical output. This is the
|
||||
strongest empirical guarantee we have that parallel workers
|
||||
won't produce inconsistent output.
|
||||
|
||||
## Why determinism matters
|
||||
|
||||
docmd is built around a **worker pool** — the build process
|
||||
shards pages across multiple threads, parses each shard
|
||||
independently, then writes the results. If the parser were
|
||||
non-deterministic (e.g. depended on a global counter, a
|
||||
timestamp, or a shared cache), two workers given the same
|
||||
input would produce different HTML. That would break:
|
||||
- Caching layers (the same content rendered twice should
|
||||
hash the same).
|
||||
- The boot-time self-test (any non-determinism crashes the
|
||||
worker at init, before the first message is processed).
|
||||
- Reproducible builds (`pnpm build` should produce the same
|
||||
output every time, given the same source).
|
||||
|
||||
## OKF classification
|
||||
|
||||
`okf.type: reference` — explicit. The page is included in the
|
||||
OKF bundle as a `reference` concept.
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
title: "F1: nested grids with one close per card"
|
||||
description: "The pre-Phase 2 parser dumped the whole grids block as raw <p>::: grids<br>::: grid<br>...<br>:::</p> text. Post-fix it renders four grid-items, each containing one card."
|
||||
okf:
|
||||
type: guide
|
||||
---
|
||||
|
||||
# F1 — nested grids with one close per card
|
||||
|
||||
This page exercises the **F1 container parser fix** from
|
||||
`battle-test-reports/test-report.md §F1`. The pattern below used
|
||||
to be the user-reported "grids don't work" bug.
|
||||
|
||||
## The pattern
|
||||
|
||||
```markdown
|
||||
::: grids
|
||||
::: grid
|
||||
::: card "Fast"
|
||||
Lightning fast.
|
||||
:::
|
||||
::: grid
|
||||
::: card "Simple"
|
||||
Zero config.
|
||||
:::
|
||||
::: grid
|
||||
::: card "Open"
|
||||
Fully open source.
|
||||
:::
|
||||
::: grid
|
||||
::: card "Live"
|
||||
Edit in browser.
|
||||
:::
|
||||
:::
|
||||
```
|
||||
|
||||
## The rendered result (post-fix)
|
||||
|
||||
::: grids
|
||||
::: grid
|
||||
::: card "Fast"
|
||||
Lightning fast.
|
||||
:::
|
||||
::: grid
|
||||
::: card "Simple"
|
||||
Zero config.
|
||||
:::
|
||||
::: grid
|
||||
::: card "Open"
|
||||
Fully open source.
|
||||
:::
|
||||
::: grid
|
||||
::: card "Live"
|
||||
Edit in browser.
|
||||
:::
|
||||
:::
|
||||
|
||||
The normaliser balances the missing closes so the `grids` rule
|
||||
matches and the inner `grid` and `card` rules can do their work.
|
||||
|
||||
## What the pre-fix output looked like
|
||||
|
||||
A single `<p>::: grids<br>::: grid<br>::: card "Fast"<br>...<br>:::</p>`
|
||||
text block. The whole nested structure was dumped as a
|
||||
paragraph of source code.
|
||||
|
||||
## OKF classification
|
||||
|
||||
This page declares `okf.type: guide` explicitly. In the OKF
|
||||
bundle, it lives in `concepts/practical-tests-phase-2-f1-nested-grids.md`
|
||||
with the `type: guide` field set.
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: "F2: self-closing button + orphan :::"
|
||||
description: "The pre-Phase 2 parser treated self-closing ::: button as a non-self-closing container, corrupting the depth counter for everything that followed. Post-fix the orphan ::: is removed with a [normaliser] WARNING."
|
||||
okf:
|
||||
type: guide
|
||||
---
|
||||
|
||||
# F2 — self-closing button + orphan `:::`
|
||||
|
||||
This page exercises the **F2 self-closing tag fix** from
|
||||
`battle-test-reports/test-report.md §F2`. The pattern below used
|
||||
to leave an orphan `:::` visible in the page AND corrupt the
|
||||
depth counter for any container that followed.
|
||||
|
||||
## The pattern
|
||||
|
||||
```markdown
|
||||
::: card "Cards with self-closing buttons"
|
||||
Some text.
|
||||
::: button "Get Started" /getting-started
|
||||
:::
|
||||
```
|
||||
|
||||
## The rendered result (post-fix)
|
||||
|
||||
::: card "Cards with self-closing buttons"
|
||||
Some text.
|
||||
::: button "Get Started" /getting-started
|
||||
:::
|
||||
|
||||
The `::: button` is a self-closing tag (per the parser's
|
||||
`SELF_CLOSING_CONTAINER_NAMES` set). The orphan `:::` is
|
||||
removed by the container normaliser with a `[normaliser] WARNING
|
||||
...` line in the build log. The card is closed correctly.
|
||||
|
||||
## What the pre-fix output looked like
|
||||
|
||||
The `::: button` was pushed to the depth stack as a normal
|
||||
container. The orphan `:::` decremented depth of the wrong
|
||||
container (not the button, not the card — an arbitrary parent
|
||||
in the parser's state). Any container after this point on the
|
||||
page would have its open/close count misaligned.
|
||||
|
||||
## OKF classification
|
||||
|
||||
`okf.type: guide` — explicit. In the OKF bundle, this page is
|
||||
classified as a `guide` and the frontmatter's `okf` block is
|
||||
preserved.
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
title: "F3: mismatched close types"
|
||||
description: "The pre-Phase 2 parser dumped the ::: callout opener as raw text when the page contained a ::: card opener in the middle. Post-fix the callout is auto-closed at EOF with a [normaliser] ERROR."
|
||||
okf:
|
||||
type: guide
|
||||
---
|
||||
|
||||
# F3 — mismatched close types
|
||||
|
||||
This page exercises the **F3 mismatched close types fix** from
|
||||
`battle-test-reports/test-report.md §F3`. The pattern below used
|
||||
to silently re-root the parser — the `::: callout` opener was
|
||||
dumped as text, the `::: card` rendered standalone, and the
|
||||
`:::` close was miscounted.
|
||||
|
||||
## The pattern
|
||||
|
||||
```markdown
|
||||
::: callout info "x"
|
||||
body
|
||||
::: card "wrong close"
|
||||
oops
|
||||
:::
|
||||
```
|
||||
|
||||
## The rendered result (post-fix)
|
||||
|
||||
::: callout info "x"
|
||||
body
|
||||
::: card "wrong close"
|
||||
oops
|
||||
:::
|
||||
|
||||
The `callout` rule matches its own body; the inner `card` rule
|
||||
matches its own body; the trailing `:::` closes the card. The
|
||||
outer callout is auto-closed at EOF with a `[normaliser] ERROR
|
||||
... Unclosed <callout> from line 1 — auto-closed at EOF` line in
|
||||
the build log.
|
||||
|
||||
## What the pre-fix output looked like
|
||||
|
||||
A `<p>::: callout info "x"<br>body</p>` paragraph (the callout
|
||||
opener was dumped as text), followed by the card rendered
|
||||
standalone — the callout's open/close count was off by 1 because
|
||||
the inner `::: card` was miscounted as a depth-incrementing
|
||||
container, so the `:::` close dropped the count to 1 instead of
|
||||
0, and the callout rule never matched.
|
||||
|
||||
## OKF classification
|
||||
|
||||
`okf.type: guide` — explicit. This page deliberately has an
|
||||
unclosed container; the OKF bundle still includes it (it
|
||||
generates a per-page warning, but does not exclude the page).
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
title: "F4: triple close erases content"
|
||||
description: "The pre-Phase 2 parser left bare ::: lines visible in the page as <p>:::</p> paragraphs. Post-fix the orphans are removed with [normaliser] WARNINGs."
|
||||
okf:
|
||||
type: guide
|
||||
---
|
||||
|
||||
# F4 — triple close erases content
|
||||
|
||||
This page exercises the **F4 triple-close / orphan `:::` fix**
|
||||
from `battle-test-reports/test-report.md §F4`. The pattern below
|
||||
used to leak `<p>:::</p>` paragraphs into the rendered page.
|
||||
|
||||
## The pattern
|
||||
|
||||
```markdown
|
||||
::: callout info "x"
|
||||
body
|
||||
:::
|
||||
:::
|
||||
:::
|
||||
```
|
||||
|
||||
## The rendered result (post-fix)
|
||||
|
||||
::: callout info "x"
|
||||
body
|
||||
:::
|
||||
:::
|
||||
:::
|
||||
|
||||
The first `:::` closes the callout (matching). The second and
|
||||
third are stray — the normaliser removes them with two
|
||||
`[normaliser] WARNING ... Stray \`:::\` removed` lines in the
|
||||
build log. The rendered page contains the callout block and
|
||||
NOTHING ELSE (no orphan `<p>:::</p>` leak).
|
||||
|
||||
## What the pre-fix output looked like
|
||||
|
||||
The callout rendered correctly (one close matched the opener).
|
||||
But the next two `:::` lines were outside any container, so
|
||||
markdown-it rendered them as a single paragraph:
|
||||
`<p>:::<br>:::</p>`. Users saw two `:::` characters inline with
|
||||
their content — a visible artefact of the parser's loose
|
||||
matching.
|
||||
|
||||
## OKF classification
|
||||
|
||||
`okf.type: guide` — explicit. Despite the unclosed trailing
|
||||
container, the page IS included in the OKF bundle (the orphan
|
||||
removal happens in the page body, not in frontmatter; OKF
|
||||
itself has no unclosed containers).
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
title: "F5: 5-level nested callouts render all 5 levels"
|
||||
description: "The pre-Phase 2 parser collapsed 5-level nested callouts into the outer level. Post-fix all 5 levels render in the output HTML."
|
||||
okf:
|
||||
type: guide
|
||||
---
|
||||
|
||||
# F5 — 5-level nested callouts
|
||||
|
||||
This page exercises the **F5 5-level nested callout fix** from
|
||||
`battle-test-reports/test-report.md §F5`. The pattern below used
|
||||
to collapse the inner levels — the user saw `<div class="callout
|
||||
callout-info">l1</div>` with only the "deep" body and no
|
||||
intermediate levels.
|
||||
|
||||
## The pattern
|
||||
|
||||
```markdown
|
||||
::: callout info "l1"
|
||||
::: callout tip "l2"
|
||||
::: callout warning "l3"
|
||||
::: callout danger "l4"
|
||||
::: callout success "l5"
|
||||
deep
|
||||
:::
|
||||
:::
|
||||
:::
|
||||
:::
|
||||
:::
|
||||
```
|
||||
|
||||
## The rendered result (post-fix)
|
||||
|
||||
::: callout info "l1"
|
||||
::: callout tip "l2"
|
||||
::: callout warning "l3"
|
||||
::: callout danger "l4"
|
||||
::: callout success "l5"
|
||||
deep
|
||||
:::
|
||||
:::
|
||||
:::
|
||||
:::
|
||||
:::
|
||||
|
||||
The container normaliser adds four implicit closes when the
|
||||
user's single `:::` close closes the outer callout. Each inner
|
||||
`callout` rule then matches its own body recursively.
|
||||
|
||||
## What the pre-fix output looked like
|
||||
|
||||
A single `<div class="callout callout-info">` containing only
|
||||
the text "deep" — the inner callouts were dropped silently
|
||||
because the depth counter never reached 0 at the expected
|
||||
position.
|
||||
|
||||
## OKF classification
|
||||
|
||||
`okf.type: guide` — explicit. The page is treated as a single
|
||||
`guide` concept in the OKF bundle. The nested callouts are
|
||||
preserved in the page body verbatim (OKF copies `rawMarkdown`).
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
title: "Phase 3 — CLI exit codes (F6, M-12)"
|
||||
description: "The pre-Phase 3 docmd CLI exited 0 on many documented failure paths. Post-fix every documented error path exits 1. This page documents the contract."
|
||||
---
|
||||
|
||||
# Phase 3 — CLI exit codes (F6, M-12)
|
||||
|
||||
This page documents the **Phase 3 PR 3.A exit-code contract fix**
|
||||
from `battle-test-reports/test-report.md §F6` and
|
||||
`battle-test-reports/more-report.md §M-12`.
|
||||
|
||||
## The contract
|
||||
|
||||
Every documented `docmd <command>` failure path MUST exit with
|
||||
a non-zero code so CI pipelines can gate on it. Before this fix,
|
||||
several paths printed a clear error message but exited 0,
|
||||
silently passing broken builds.
|
||||
|
||||
## The 5 paths that were fixed
|
||||
|
||||
| Command | Failure case | Pre-fix exit | Post-fix exit |
|
||||
|---|---|---|---|
|
||||
| `docmd build` | Unknown plugin in config | 0 | 1 |
|
||||
| `docmd migrate` | No `--docusaurus`/`--mkdocs`/etc. flag | 0 | 1 |
|
||||
| `docmd migrate --help` | (it's a help print) | 0 | 0 (unchanged) |
|
||||
| `docmd remove <nonexistent>` | Plugin not in registry | 0 | 1 |
|
||||
| `docmd validate --json` | Broken links in any page | 0 | 1 |
|
||||
|
||||
## The mechanism
|
||||
|
||||
A new helper module `packages/core/src/utils/exit.ts` exports
|
||||
three functions:
|
||||
|
||||
- `exitCodeFor(err)` — returns the numeric exit code. Today
|
||||
always 1 (operational error); the indirection lets us
|
||||
introduce code-2/3/4 later without rewriting every call site.
|
||||
- `exitWithError(err, opts?)` — calls `TUI.error` (or prints
|
||||
JSON when `opts.json === true`) and `process.exit(code)`.
|
||||
- `failWith(message, opts?)` — convenience wrapper for the
|
||||
"plain message, no Error object" case.
|
||||
|
||||
For `migrate` and `remove`, the `TUI.error(...); return;` pattern
|
||||
was replaced with `process.exit(1)` (or, in `migrate.ts`, a
|
||||
`exitWithError` call). For `build`, the new `getPluginLoadErrors()`
|
||||
in `@docmd/api` returns a list of plugins that failed to load,
|
||||
and `build.ts` throws if the list is non-empty — the existing
|
||||
catch block turns the throw into `process.exit(1)`.
|
||||
|
||||
## How to verify
|
||||
|
||||
```bash
|
||||
cd packages/_playground
|
||||
|
||||
# F6: unknown plugin → exit 1
|
||||
mkdir -p /tmp/f6 && cd /tmp/f6
|
||||
echo '{"title":"F6","plugins":{"nope":{}}}' > docmd.config.json
|
||||
node ../../packages/core/dist/bin/docmd.js build
|
||||
echo $? # 1
|
||||
|
||||
# M-12: validate --json with broken links → exit 1
|
||||
cd /tmp/m12 && echo '[bad](/nope)' > docs/index.md
|
||||
node ../../packages/core/dist/bin/docmd.js validate --json
|
||||
echo $? # 1
|
||||
|
||||
# Sanity: --help still exits 0
|
||||
node ../../packages/core/dist/bin/docmd.js migrate --help
|
||||
echo $? # 0
|
||||
```
|
||||
|
||||
The test matrix is `scripts/brute-test.js` test 26 (7 assertions,
|
||||
regression-tested in the categorised test suite under
|
||||
`tests/cli-contracts/exit-codes.test.js`).
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
---
|
||||
title: "M-1: internal link with trailing slash"
|
||||
description: "The pre-Phase 3 docmd validate command false-positived every internal link with a trailing slash. Post-fix the trailing slash is stripped before the .md existence check."
|
||||
---
|
||||
|
||||
# M-1 — internal link with trailing slash
|
||||
|
||||
This page exercises the **M-1 trailing-slash false-positive fix**
|
||||
from `battle-test-reports/more-report.md §M-1`. The link below
|
||||
used to be reported as a broken link by `docmd validate` even
|
||||
though the target file existed.
|
||||
|
||||
## The pattern
|
||||
|
||||
A markdown link with a trailing slash to a sibling page (one
|
||||
directory up, then down into `phase-2`):
|
||||
|
||||
```markdown
|
||||
Links to [F1 grids test](../phase-2/f1-nested-grids/).
|
||||
```
|
||||
|
||||
## The rendered result (post-fix)
|
||||
|
||||
Links to [F1 grids test](../phase-2/f1-nested-grids/).
|
||||
|
||||
The link's `href` is `../../phase-2/f1-nested-grids/`. The build
|
||||
treats this as equivalent to `../../phase-2/f1-nested-grids`
|
||||
(without slash) — both resolve to the same generated HTML page.
|
||||
`docmd validate` now agrees.
|
||||
|
||||
## What the pre-fix output looked like
|
||||
|
||||
`docmd validate` reported:
|
||||
|
||||
```
|
||||
[docs/en/practical-tests/phase-3/internal-link-with-trailing-slash.md:N]
|
||||
../../phase-2/f1-nested-grids/ -> Broken link: target resolved to
|
||||
docs/en/practical-tests/phase-2/f1-nested-grids does not exist
|
||||
```
|
||||
|
||||
The validator did `fs.existsSync('docs/en/practical-tests/phase-2/f1-nested-grids/.md')`
|
||||
which is always false. CI pipelines that gated on
|
||||
`docmd validate` exited 0 (because exit 0 is the default for
|
||||
no-broken-links), so broken-link regressions slipped through.
|
||||
|
||||
## How to verify
|
||||
|
||||
```bash
|
||||
cd packages/_playground
|
||||
node ../../packages/core/dist/bin/docmd.js validate
|
||||
# exit 0 — no broken links
|
||||
node ../../packages/core/dist/bin/docmd.js validate --json | jq '.errors | length'
|
||||
# 0
|
||||
```
|
||||
|
||||
The fix lives in `packages/core/src/commands/mcp.ts`
|
||||
`validateLinks()` — a single `stripped` variable drops the
|
||||
trailing `/` before the four `fs.existsSync` checks.
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
title: "LLMS and OKF: default-locale-only by default"
|
||||
description: "Both the @docmd/plugin-llms and @docmd/plugin-okf plugins write the default locale's files at the bundle root by default. Non-default locales are only written when the user explicitly opts in via plugins.llms.i18n: true or plugins.okf.localeStrategy: 'folders'."
|
||||
---
|
||||
|
||||
# LLMS and OKF — default-locale-only by default
|
||||
|
||||
This page documents the **i18n contract** for the two AI-agent
|
||||
plugins shipped with docmd 0.8.8:
|
||||
|
||||
- `@docmd/plugin-llms` — generates `llms.txt` / `llms-full.txt` / `llms.json`
|
||||
- `@docmd/plugin-okf` — generates the OKF bundle (`site/okf/`)
|
||||
|
||||
Both plugins used to write **per-locale** output by default. That
|
||||
default was wrong for two reasons:
|
||||
|
||||
1. The `llms.txt` filename is the standard single-file name that
|
||||
downstream consumers (Cursor, Claude, GPT, etc.) look for.
|
||||
Splitting it into `llms.en.txt` + `llms.hi.txt` + `llms.fr.txt`
|
||||
broke every existing integration.
|
||||
|
||||
2. The OKF bundle is meant to be a "knowledge base" — the user
|
||||
doesn't want to push 6 locale variants of every concept to a
|
||||
vector store. They want the primary-language version, with
|
||||
non-default locales as opt-in extras.
|
||||
|
||||
## The new default
|
||||
|
||||
| Plugin | Default behaviour | File name |
|
||||
|---|---|---|
|
||||
| `llms` | Default locale only | `llms.txt`, `llms-full.txt`, `llms.json` |
|
||||
| `okf` | Default locale only | `site/okf/okf.yaml`, `site/okf/concepts/*.md`, etc. |
|
||||
|
||||
The default locale is whatever `config.i18n.default` resolves to.
|
||||
Single-locale sites (no `config.i18n` block) are unaffected — they
|
||||
get the same single set of files as before.
|
||||
|
||||
## Opting into multi-locale
|
||||
|
||||
Set the corresponding option in `docmd.config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"llms": { "i18n": true },
|
||||
"okf": { "localeStrategy": "folders" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
With both options set, the plugins write per-locale output:
|
||||
|
||||
```
|
||||
site/llms.txt ← default locale (en) — UNSUFFIXED
|
||||
site/llms-full.txt ← default locale (en) — UNSUFFIXED
|
||||
site/llms.json ← default locale (en) — UNSUFFIXED
|
||||
site/llms.hi.txt ← hi — suffixed
|
||||
site/llms-full.hi.txt ← hi — suffixed
|
||||
site/llms.hi.json ← hi — suffixed
|
||||
site/llms.fr.txt ← fr — suffixed
|
||||
site/llms-full.fr.txt ← fr — suffixed
|
||||
site/llms.fr.json ← fr — suffixed
|
||||
|
||||
site/okf/ ← default locale
|
||||
├── okf.yaml
|
||||
├── index.md
|
||||
├── concepts/
|
||||
│ └── root.md
|
||||
└── _meta/, graph.html, graph.json, ...
|
||||
|
||||
site/okf/hi/ ← hi — suffixed directory
|
||||
├── concepts/
|
||||
│ └── hi.md
|
||||
|
||||
site/okf/fr/ ← fr — suffixed directory
|
||||
├── concepts/
|
||||
│ └── fr.md
|
||||
```
|
||||
|
||||
Notice the pattern: **the default locale never gets a suffix or a
|
||||
subdirectory** — it sits at the bundle root where existing
|
||||
consumers expect it. Only the non-default locales get the
|
||||
`<locale>/` (OKF) or `.<locale>` (LLMS) suffix.
|
||||
|
||||
## How to verify
|
||||
|
||||
The current `_playground` is a single-locale project (en only),
|
||||
so both plugins write a single set of files at the root — no
|
||||
suffix, no subdirectory. To test multi-locale, set
|
||||
`config.i18n.locales` to include more than one locale AND enable
|
||||
the opt-in flags shown above.
|
||||
|
||||
## OKF classification
|
||||
|
||||
This page sits at `/en/practical-tests/phase-3/llms-and-okf-default-locale/`
|
||||
which doesn't match any of the OKF type-inference prefixes
|
||||
(`/api/`, `/guides/`, etc.). It falls back to the default type:
|
||||
`concept`. In the OKF bundle, it lives at
|
||||
`site/okf/concepts/llms-and-okf-default-locale.md`.
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: "OKF default-enabled + type inference"
|
||||
description: "This page is included in the OKF bundle by default (no plugins.okf config needed), demonstrates the path-prefix type inference map, and shows the noindex + okf: false opt-out paths in the body."
|
||||
---
|
||||
|
||||
# OKF — default-enabled + type inference
|
||||
|
||||
This page exercises the **OKF plugin (0.8.8)**:
|
||||
|
||||
- **Default-enabled** — the `site/okf/` bundle is generated
|
||||
even when `docmd.config.json` has no `plugins.okf` entry.
|
||||
See [`README.md` of `@docmd/plugin-okf`][okf-readme] for the
|
||||
callout.
|
||||
- **Type inference** — pages under `/api/` are auto-classified
|
||||
as `api`, pages under `/guides/` as `guide`, etc. The path
|
||||
prefix map is documented in the OKF README.
|
||||
- **Per-page opt-out** — pages with `noindex: true` or
|
||||
`okf: false` in frontmatter are excluded from the bundle.
|
||||
|
||||
[okf-readme]: ../../../../plugins/okf/README.md
|
||||
|
||||
## How OKF classifies this page
|
||||
|
||||
This page is at `/en/practical-tests/phase-3/okf-bundle-default/`
|
||||
which doesn't match any of the type-inference prefixes
|
||||
(`guides/`, `api/`, `reference/`, `concepts/`, `runbooks/`,
|
||||
`datasets/`, `metrics/`, `tables/`). So OKF falls back to the
|
||||
default type: `concept`. Look at `site/okf/okf.yaml` after
|
||||
the build to see this page listed under the `concept` type.
|
||||
|
||||
## OKF: false opt-out
|
||||
|
||||
A page with this frontmatter would be excluded from the bundle:
|
||||
|
||||
```markdown
|
||||
---
|
||||
okf: false
|
||||
---
|
||||
```
|
||||
|
||||
We don't actually use that here (because we want to verify the
|
||||
page IS in the bundle), but the OKF plugin's `onPostBuild`
|
||||
filters pages where `p.frontmatter.okf === false`.
|
||||
|
||||
## `noindex: true` opt-out
|
||||
|
||||
A page with `noindex: true` is excluded from the OKF bundle
|
||||
AND from the sitemap, search index, and llms.txt. This is the
|
||||
standard docmd opt-out — applies to multiple plugins uniformly.
|
||||
|
||||
## What to inspect after the build
|
||||
|
||||
After running `pnpm docmd build` from `packages/_playground/`:
|
||||
|
||||
```bash
|
||||
ls site/okf/concepts/ | head # one .md per page
|
||||
cat site/okf/okf.yaml | head -30 # typed manifest
|
||||
open site/okf/graph.html # interactive graph viewer
|
||||
```
|
||||
|
||||
The `okf.yaml` file lists every concept in the bundle with its
|
||||
type, path, locale, and version. The graph viewer renders a
|
||||
force-directed graph of the concept relationships inferred from
|
||||
internal markdown links.
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: "OKF type inference — no frontmatter needed"
|
||||
description: "OKF classifies pages by URL path prefix even when frontmatter has no okf.type. /api/ → api, /guides/ → guide, etc. This page sits in /en/practical-tests/ which doesn't match any prefix, so it falls back to the default type 'concept'."
|
||||
---
|
||||
|
||||
# OKF type inference — no frontmatter needed
|
||||
|
||||
This page exercises the **OKF type inference** feature: when a
|
||||
page has no explicit `okf.type` in frontmatter, OKF infers a
|
||||
type from the URL path prefix.
|
||||
|
||||
## The inference map
|
||||
|
||||
| URL prefix | Inferred type |
|
||||
|---|---|
|
||||
| `/api/` | `api` |
|
||||
| `/guides/` | `guide` |
|
||||
| `/reference/` | `reference` |
|
||||
| `/concepts/` | `concept` |
|
||||
| `/runbooks/` | `runbook` |
|
||||
| `/datasets/` | `dataset` |
|
||||
| `/metrics/` | `metric` |
|
||||
| `/tables/` | `table` |
|
||||
| (anything else) | `concept` (the defaultType) |
|
||||
|
||||
The map is implemented in `packages/plugins/okf/src/index.ts`
|
||||
`PATH_TYPE_MAP`.
|
||||
|
||||
## This page's inferred type
|
||||
|
||||
This page is at `/en/practical-tests/phase-3/okf-type-inference/`.
|
||||
That path doesn't match any of the inference prefixes. The
|
||||
page has no `okf.type` in frontmatter, so OKF falls back to the
|
||||
default type: **`concept`**. Look at `site/okf/okf.yaml` after
|
||||
the build to see this page classified under the `concept`
|
||||
type.
|
||||
|
||||
## What happens with explicit frontmatter
|
||||
|
||||
If this page had:
|
||||
|
||||
```yaml
|
||||
---
|
||||
okf:
|
||||
type: api
|
||||
---
|
||||
```
|
||||
|
||||
…then OKF would classify it as `api` (explicit overrides
|
||||
inferred). The precedence in `resolveType()` is:
|
||||
|
||||
1. `frontmatter.okf.type` (nested) — explicit
|
||||
2. `frontmatter.<typeField>` (top-level) — usually `type:` in
|
||||
modern docmd pages
|
||||
3. `frontmatter.okfType` (legacy) — old alias
|
||||
4. Path-prefix inference (this section)
|
||||
5. `defaultType` from config (default: `concept`)
|
||||
|
||||
## How to verify
|
||||
|
||||
After `pnpm docmd build` from `packages/_playground/`:
|
||||
|
||||
```bash
|
||||
grep -A 2 "okf-type-inference" site/okf/okf.yaml
|
||||
# Should list this page with type: concept
|
||||
```
|
||||
|
||||
To see explicit-vs-inferred, edit the frontmatter of this page
|
||||
to add `okf.type: guide` and rebuild. The YAML will show
|
||||
`type: guide` instead of `type: concept`.
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
title: "T-S3: XSS payloads in frontmatter are HTML-escaped"
|
||||
description: "The pre-Phase 1 build rendered XSS payloads in frontmatter title and description fields as live HTML in og:/twitter: meta tags and the page <title>. Post-fix every interpolation goes through attrEsc() or escHtml()."
|
||||
---
|
||||
|
||||
# T-S3 — XSS payloads in frontmatter are HTML-escaped
|
||||
|
||||
This page exercises the **T-S3 XSS via og:/twitter: meta tags
|
||||
fix** from `battle-test-reports/test-report.md §S3` and
|
||||
`§T-S3`. The frontmatter below contains classic XSS payloads.
|
||||
Pre-Phase 1 they were rendered as live HTML in the generated
|
||||
page. Post-fix they are HTML-escaped everywhere they appear.
|
||||
|
||||
## The pattern
|
||||
|
||||
The frontmatter of this page contains:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: '"><img src=x onerror=alert(1)> - Site'
|
||||
description: '<script>alert("fm-xss")</script> Page description'
|
||||
---
|
||||
```
|
||||
|
||||
## The rendered result (post-fix)
|
||||
|
||||
The `<title>` of the generated page contains the escaped version:
|
||||
|
||||
```html
|
||||
<title>"><img src=x onerror=alert(1)> - Site</title>
|
||||
```
|
||||
|
||||
The og:title / twitter:title meta tags:
|
||||
|
||||
```html
|
||||
<meta property="og:title" content=""><img src=x onerror=alert(1)> - Site">
|
||||
<meta name="twitter:title" content=""><img src=x onerror=alert(1)> - Site">
|
||||
```
|
||||
|
||||
No live `<script>alert(1)</script>` in the page. No `onerror`
|
||||
handler. The payloads are visible in the browser as literal
|
||||
text — exactly what the user typed.
|
||||
|
||||
## What the pre-fix output looked like
|
||||
|
||||
```html
|
||||
<title>"><img src=x onerror=alert(1)> - Site</title>
|
||||
<meta property="og:title" content=""><img src=x onerror=alert(1)> - Site">
|
||||
```
|
||||
|
||||
The XSS payload fired as soon as the page loaded. CI pipelines
|
||||
that scanned for `<script>alert(` would have flagged it, but
|
||||
the escaped version is what users see in their browser now.
|
||||
|
||||
## How to verify
|
||||
|
||||
```bash
|
||||
cd packages/_playground
|
||||
node ../../packages/core/dist/bin/docmd.js build
|
||||
grep -c "onerror" site/en/practical-tests/phase-3/xss-in-frontmatter/index.html
|
||||
# 0
|
||||
grep -c "<img src=x" site/en/practical-tests/phase-3/xss-in-frontmatter/index.html
|
||||
# at least 1 (the escaped version)
|
||||
```
|
||||
|
||||
The fix lives in:
|
||||
- `packages/plugins/seo/src/index.ts` (og:title, twitter:title)
|
||||
- `packages/plugins/pwa/src/index.ts` (themeColor — separate fix)
|
||||
- `packages/plugins/analytics/src/index.ts` (measurementId)
|
||||
- `packages/core/src/utils/exit.ts` (generalised esc helpers from
|
||||
Phase 0 + Phase 3 PR 3.A)
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
title: "docmd _playground (हिन्दी)"
|
||||
---
|
||||
|
||||
यह प्लेग्राउंड कोर इंजन परिवर्तनों को वास्तविक समय में सत्यापित करने के लिए उपयोग किया जाता है।
|
||||
|
||||
## घटक सत्यापन
|
||||
|
||||
अपने UI घटकों और पार्सर नियमों का परीक्षण करें।
|
||||
|
||||
::: callout info
|
||||
### यह एक हिन्दी पृष्ठ है
|
||||
यह पृष्ठ हिन्दी में अनुवादित है। अन्य पृष्ठ जो अनुवादित नहीं हैं, वे अंग्रेज़ी में फ़ॉलबैक दिखाएंगे।
|
||||
:::
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: "एफ1: नेस्टेड ग्रिड्स (Hindi locale test)"
|
||||
description: "Hindi locale page testing F1 nested-grids fix + OKF locale strategy 'folders'. The page is at /hi/practical-tests/phase-2-f1-hindi/ so OKF nests it under hi/concepts/ in the bundle."
|
||||
---
|
||||
|
||||
# एफ1 — नेस्टेड ग्रिड्स (Hindi locale test)
|
||||
|
||||
This page exercises the **F1 nested-grids fix** in the **Hindi
|
||||
locale** (`hi`) to confirm the parser fix and the OKF locale
|
||||
strategy both work for non-English content.
|
||||
|
||||
::: grids
|
||||
::: grid
|
||||
::: card "तेज़"
|
||||
बिजली की तेज़ी।
|
||||
:::
|
||||
::: grid
|
||||
::: card "सरल"
|
||||
ज़ीरो कॉन्फ़िग।
|
||||
:::
|
||||
:::
|
||||
|
||||
## OKF locale strategy
|
||||
|
||||
When `localeStrategy: 'folders'` is set (the default), the OKF
|
||||
plugin nests concepts under their locale in the bundle:
|
||||
|
||||
```
|
||||
site/okf/
|
||||
├── en/
|
||||
│ └── concepts/
|
||||
│ └── ...
|
||||
├── hi/
|
||||
│ └── concepts/
|
||||
│ └── practical-tests-phase-2-f1-hindi.md
|
||||
```
|
||||
|
||||
This page's slug is `practical-tests-phase-2-f1-hindi` and it
|
||||
lives at `site/okf/hi/concepts/practical-tests-phase-2-f1-hindi.md`
|
||||
after the build.
|
||||
@@ -0,0 +1,27 @@
|
||||
# ---------------------------------------------------
|
||||
# netlify.toml generated by docmd v0.8.3
|
||||
# Project: _playground
|
||||
# https://docmd.io/deployment/netlify
|
||||
# ---------------------------------------------------
|
||||
|
||||
[build]
|
||||
command = "npm install -g @docmd/core@0.8.3 && docmd build"
|
||||
publish = "site"
|
||||
|
||||
[build.environment]
|
||||
NODE_VERSION = "20"
|
||||
|
||||
[[headers]]
|
||||
for = "/assets/*"
|
||||
[headers.values]
|
||||
Cache-Control = "public, max-age=15552000, immutable"
|
||||
|
||||
[[headers]]
|
||||
for = "/*.html"
|
||||
[headers.values]
|
||||
Cache-Control = "public, max-age=0, must-revalidate"
|
||||
|
||||
[[redirects]]
|
||||
from = "/*"
|
||||
to = "/index.html"
|
||||
status = 200
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@docmd/playground",
|
||||
"version": "0.8.12",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@docmd/core": "workspace:*",
|
||||
"@docmd/plugin-math": "workspace:*",
|
||||
"@docmd/template-summer": "workspace:*",
|
||||
"@huggingface/transformers": "^4.2.0",
|
||||
"onnxruntime-node": "^1.27.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "node ../core/dist/bin/docmd.js dev",
|
||||
"build": "node ../core/dist/bin/docmd.js build"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||
"buildCommand": "docmd build",
|
||||
"outputDirectory": "site",
|
||||
"installCommand": "npm install && npm install -g @docmd/core@0.8.3",
|
||||
"framework": null,
|
||||
"headers": [
|
||||
{
|
||||
"source": "/assets/(.*)",
|
||||
"headers": [
|
||||
{ "key": "Cache-Control", "value": "public, max-age=15552000, immutable" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "/(.*).html",
|
||||
"headers": [
|
||||
{ "key": "Cache-Control", "value": "public, max-age=0, must-revalidate" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{ "handle": "filesystem" },
|
||||
{ "src": "/(.*)", "dest": "/index.html" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025-present docmd.io
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,27 @@
|
||||
# @docmd/api
|
||||
|
||||
Plugin API surface for docmd - hook registration, RPC dispatch, and source editing tools.
|
||||
|
||||
This package is the canonical home for the docmd plugin system. It provides:
|
||||
|
||||
- **Plugin Loader** - Validates, isolates, and registers plugins with capability-gated hooks
|
||||
- **Action Dispatcher** - WebSocket RPC system for live-edit actions and fire-and-forget events
|
||||
- **Source Tools** - Block-level markdown source editing (insert, replace, wrap, remove)
|
||||
- **Type Definitions** - `PluginDescriptor`, `PluginModule`, `ActionContext`, `SourceTools`, and more
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { loadPlugins, createActionDispatcher, createSourceTools } from '@docmd/api';
|
||||
import type { PluginDescriptor, PluginModule } from '@docmd/api';
|
||||
```
|
||||
|
||||
Part of the **[docmd](https://github.com/docmd-io/docmd)** documentation engine.
|
||||
|
||||
## Documentation
|
||||
|
||||
See **[docs.docmd.io](https://docs.docmd.io)** for full usage and API reference.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "@docmd/api",
|
||||
"version": "0.8.12",
|
||||
"description": "Plugin API surface for docmd.",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build:registry": "node ../../scripts/build-plugin-registry.mjs",
|
||||
"prebuild": "npm run build:registry",
|
||||
"build": "tsc && node scripts/copy-registry.mjs"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"registry"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docmd/parser": "workspace:*",
|
||||
"@docmd/tui": "workspace:*",
|
||||
"@docmd/utils": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@docmd/engine-js": "workspace:*",
|
||||
"@docmd/engine-rust": "workspace:*"
|
||||
},
|
||||
"keywords": [
|
||||
"docmd",
|
||||
"documentation",
|
||||
"api",
|
||||
"plugin",
|
||||
"hooks",
|
||||
"rpc",
|
||||
"markdown",
|
||||
"minimalist",
|
||||
"zero-config",
|
||||
"site-generator",
|
||||
"static-site-generator",
|
||||
"docs"
|
||||
],
|
||||
"author": {
|
||||
"name": "Ghazi",
|
||||
"url": "https://mgks.dev"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/docmd-io/docmd.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/docmd-io/docmd/issues"
|
||||
},
|
||||
"homepage": "https://docmd.io",
|
||||
"funding": "https://github.com/sponsors/mgks",
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
{
|
||||
"$meta": {
|
||||
"generatedAt": "2026-07-12T12:23:44.202Z",
|
||||
"generator": "scripts/build-plugin-registry.mjs",
|
||||
"packageCount": 15
|
||||
},
|
||||
"analytics": {
|
||||
"package": "@docmd/plugin-analytics",
|
||||
"description": "Analytics injection plugin for docmd.",
|
||||
"configKey": "analytics",
|
||||
"defaultConfig": "{}",
|
||||
"kind": "plugin",
|
||||
"displayName": "Analytics",
|
||||
"tagline": "Analytics injection plugin for docmd.",
|
||||
"capabilities": [
|
||||
"head",
|
||||
"body"
|
||||
]
|
||||
},
|
||||
"git": {
|
||||
"package": "@docmd/plugin-git",
|
||||
"description": "Git integration plugin for docmd.",
|
||||
"configKey": "git",
|
||||
"defaultConfig": "{}",
|
||||
"kind": "plugin",
|
||||
"displayName": "Git",
|
||||
"tagline": "Git integration with last-updated timestamps and commit history.",
|
||||
"capabilities": [
|
||||
"init",
|
||||
"build",
|
||||
"body",
|
||||
"assets",
|
||||
"post-build",
|
||||
"translations"
|
||||
]
|
||||
},
|
||||
"llms": {
|
||||
"package": "@docmd/plugin-llms",
|
||||
"description": "Generate llms.txt context files for AI agents from your docmd site.",
|
||||
"configKey": "llms",
|
||||
"defaultConfig": "{}",
|
||||
"kind": "plugin",
|
||||
"displayName": "LLMs",
|
||||
"tagline": "Generates llms.txt and llms-full.txt for LLM consumption.",
|
||||
"capabilities": [
|
||||
"post-build"
|
||||
]
|
||||
},
|
||||
"math": {
|
||||
"package": "@docmd/plugin-math",
|
||||
"description": "Mathematics (KaTeX/LaTeX) plugin for docmd.",
|
||||
"configKey": "math",
|
||||
"defaultConfig": "{}",
|
||||
"kind": "plugin",
|
||||
"displayName": "Math",
|
||||
"tagline": "Mathematics (KaTeX/LaTeX) plugin for docmd.",
|
||||
"capabilities": [
|
||||
"markdown",
|
||||
"assets"
|
||||
]
|
||||
},
|
||||
"mermaid": {
|
||||
"package": "@docmd/plugin-mermaid",
|
||||
"description": "Mermaid diagram support plugin for docmd.",
|
||||
"configKey": "mermaid",
|
||||
"defaultConfig": "{}",
|
||||
"kind": "plugin",
|
||||
"displayName": "Mermaid",
|
||||
"tagline": "Mermaid.js diagram support for docmd.",
|
||||
"capabilities": [
|
||||
"markdown",
|
||||
"assets"
|
||||
]
|
||||
},
|
||||
"okf": {
|
||||
"package": "@docmd/plugin-okf",
|
||||
"description": "Generate an Open Knowledge Format (OKF) bundle from your docmd site for AI agent consumption.",
|
||||
"configKey": "okf",
|
||||
"defaultConfig": "{}",
|
||||
"kind": "plugin",
|
||||
"displayName": "OKF",
|
||||
"tagline": "Open Knowledge Format (OKF) bundle generator for AI-agent consumption.",
|
||||
"capabilities": [
|
||||
"post-build"
|
||||
]
|
||||
},
|
||||
"openapi": {
|
||||
"package": "@docmd/plugin-openapi",
|
||||
"description": "OpenAPI documentation generator plugin for docmd.",
|
||||
"configKey": "openapi",
|
||||
"defaultConfig": "{}",
|
||||
"kind": "plugin",
|
||||
"displayName": "OpenAPI",
|
||||
"tagline": "OpenAPI / Swagger spec rendering for docmd.",
|
||||
"capabilities": [
|
||||
"markdown",
|
||||
"assets"
|
||||
]
|
||||
},
|
||||
"pwa": {
|
||||
"package": "@docmd/plugin-pwa",
|
||||
"description": "Progressive Web App (PWA) plugin for docmd.",
|
||||
"configKey": "pwa",
|
||||
"defaultConfig": "{}",
|
||||
"kind": "plugin",
|
||||
"displayName": "PWA",
|
||||
"tagline": "Progressive Web App support for docmd with offline caching.",
|
||||
"capabilities": [
|
||||
"post-build",
|
||||
"head",
|
||||
"body"
|
||||
]
|
||||
},
|
||||
"search": {
|
||||
"package": "@docmd/plugin-search",
|
||||
"description": "Offline full-text search for docmd.",
|
||||
"configKey": "search",
|
||||
"defaultConfig": "{}",
|
||||
"kind": "plugin",
|
||||
"displayName": "Search",
|
||||
"tagline": "Offline full-text search for docmd.",
|
||||
"capabilities": [
|
||||
"post-build",
|
||||
"init",
|
||||
"head",
|
||||
"body",
|
||||
"assets",
|
||||
"translations"
|
||||
]
|
||||
},
|
||||
"seo": {
|
||||
"package": "@docmd/plugin-seo",
|
||||
"description": "SEO meta tag generator for docmd.",
|
||||
"configKey": "seo",
|
||||
"defaultConfig": "{}",
|
||||
"kind": "plugin",
|
||||
"displayName": "SEO",
|
||||
"tagline": "SEO meta tag generator for docmd.",
|
||||
"capabilities": [
|
||||
"head",
|
||||
"post-build"
|
||||
]
|
||||
},
|
||||
"sitemap": {
|
||||
"package": "@docmd/plugin-sitemap",
|
||||
"description": "Sitemap generator plugin for docmd.",
|
||||
"configKey": "sitemap",
|
||||
"defaultConfig": "{}",
|
||||
"kind": "plugin",
|
||||
"displayName": "Sitemap",
|
||||
"tagline": "Sitemap generator for docmd.",
|
||||
"capabilities": [
|
||||
"post-build"
|
||||
]
|
||||
},
|
||||
"threads": {
|
||||
"package": "@docmd/plugin-threads",
|
||||
"description": "Inline discussion threads for docmd.",
|
||||
"configKey": "threads",
|
||||
"defaultConfig": "{}",
|
||||
"kind": "plugin",
|
||||
"displayName": "Threads",
|
||||
"tagline": "Inline discussion comments stored in Markdown.",
|
||||
"capabilities": [
|
||||
"markdown",
|
||||
"body",
|
||||
"assets",
|
||||
"actions",
|
||||
"translations"
|
||||
]
|
||||
},
|
||||
"summer": {
|
||||
"package": "@docmd/template-summer",
|
||||
"description": "Summer template for docmd. A bright, hopeful, summer-feel layout with a top search bar and halo accents.",
|
||||
"configKey": "summer",
|
||||
"defaultConfig": "{}",
|
||||
"kind": "template",
|
||||
"displayName": "Summer",
|
||||
"tagline": "A bright, hopeful, summer-feel layout with a top search bar and halo accents.",
|
||||
"capabilities": [
|
||||
"template"
|
||||
],
|
||||
"preview": "assets/css/summer.css"
|
||||
},
|
||||
"js": {
|
||||
"package": "@docmd/engine-js",
|
||||
"description": "Default JavaScript engine for docmd using Node.js native APIs.",
|
||||
"configKey": "js",
|
||||
"defaultConfig": "{}",
|
||||
"kind": "engine",
|
||||
"displayName": "js",
|
||||
"tagline": "Default JavaScript engine for docmd using Node.js native APIs.",
|
||||
"capabilities": []
|
||||
},
|
||||
"rust": {
|
||||
"package": "@docmd/engine-rust",
|
||||
"description": "Rust-accelerated engine for docmd. The postinstall script downloads a pre-built platform binary from npm CDN (unpkg/jsdelivr). No code is executed beyond copying the .node file. Source: https://github.com/docmd-io/docmd",
|
||||
"configKey": "rust",
|
||||
"defaultConfig": "{}",
|
||||
"kind": "engine",
|
||||
"displayName": "rust",
|
||||
"tagline": "Rust-accelerated engine for docmd. The postinstall script downloads a pre-built platform binary from npm CDN (unpkg/jsdelivr). No code is executed beyond copying the .node file. Source: https://github.com/docmd-io/docmd",
|
||||
"capabilities": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Copy the generated plugin registry from `registry/` to `dist/registry/`
|
||||
* so the published package's runtime loader (which resolves paths from
|
||||
* `dist/`) can find it without depending on the `files` field layout.
|
||||
*
|
||||
* The `files: ["dist", "registry"]` array publishes both, so users can
|
||||
* also resolve directly from the package root, but the dist/ copy is
|
||||
* what the loader prefers (it's the path relative to __dirname).
|
||||
*/
|
||||
import { copyFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const PKG_ROOT = resolve(__dirname, '..');
|
||||
|
||||
const src = join(PKG_ROOT, 'registry', 'plugins.generated.json');
|
||||
const destDir = join(PKG_ROOT, 'dist', 'registry');
|
||||
const dest = join(destDir, 'plugins.generated.json');
|
||||
|
||||
if (!existsSync(src)) {
|
||||
console.error('✗ copy-registry: source missing:', src);
|
||||
console.error(' Run `node ../../scripts/build-plugin-registry.mjs` first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true });
|
||||
copyFileSync(src, dest);
|
||||
console.log(`✓ Copied registry to ${dest}`);
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* @package @docmd/api
|
||||
* @website https://docmd.io
|
||||
* @repository https://github.com/docmd-io/docmd
|
||||
* @license MIT
|
||||
* @copyright Copyright (c) 2025-present docmd.io
|
||||
*
|
||||
* [docmd-source] - Please do not remove this header.
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import type { Engine, EngineResult } from './types.js';
|
||||
import { engineRegistry, registerEngine } from './types.js';
|
||||
import { installRuntimeDep, tryLoadAfterInstall, isValidRuntimeDepName } from './runtime-deps.js';
|
||||
export { engineRegistry, registerEngine };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Allowed task types — the API layer acts as a security boundary between
|
||||
// plugins and engines. Plugins may only request tasks in this set.
|
||||
// Engines themselves are unrestricted and can implement any task type.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ALLOWED_TASK_TYPES = new Set([
|
||||
'file:discover',
|
||||
'file:read',
|
||||
'file:readBatch',
|
||||
'file:write',
|
||||
'file:exists',
|
||||
'git:log',
|
||||
'git:status',
|
||||
'search:index',
|
||||
'search:query',
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Engine Loading
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Load an engine by name.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. Custom loader registered via `registerEngine()`
|
||||
* 2. Built-in engines: 'js' (always available), 'rust' (optional, native binary)
|
||||
*
|
||||
* The Rust engine gracefully falls back to JS if the native binary is not
|
||||
* installed, so callers can always specify 'rust' without special-casing.
|
||||
*/
|
||||
export async function loadEngine(name: string = 'js'): Promise<Engine> {
|
||||
// Custom loader takes priority
|
||||
const loader = engineRegistry.get(name);
|
||||
if (loader) {
|
||||
const engine = await loader();
|
||||
if (engine) return engine;
|
||||
}
|
||||
|
||||
if (name === 'js') {
|
||||
// JS engine is a hard requirement. Auto-install if it's missing
|
||||
// (e.g. user opted out of @docmd/api optionalDependencies at
|
||||
// install time), then retry once. There is no JS fallback for a
|
||||
// JS engine — call it a fatal loader failure if install also fails.
|
||||
try {
|
||||
const { createJsEngine } = await import('@docmd/engine-js');
|
||||
return createJsEngine();
|
||||
} catch (err) {
|
||||
if (!isValidRuntimeDepName('@docmd/engine-js')) throw err;
|
||||
const installed = await installRuntimeDep('@docmd/engine-js');
|
||||
if (installed) {
|
||||
const reloaded = await tryLoadAfterInstall('@docmd/engine-js');
|
||||
if (reloaded) {
|
||||
const { createJsEngine } = reloaded as any;
|
||||
return createJsEngine();
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`[docmd] JS engine unavailable after auto-install: ${(err as Error).message}. ` +
|
||||
`Add "@docmd/engine-js" to your package.json dependencies.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (name === 'rust') {
|
||||
// Rust engine is an optional performance accelerator. Try to load
|
||||
// it; if the package is missing, auto-install it; if install fails
|
||||
// OR the binary isn't usable on this platform, fall back to JS.
|
||||
try {
|
||||
const { createRustEngine, isRustEngineAvailable } = await import('@docmd/engine-rust');
|
||||
if (!isRustEngineAvailable()) {
|
||||
console.warn('[docmd] Rust engine not supported on this platform, falling back to JS engine.');
|
||||
return loadEngine('js');
|
||||
}
|
||||
return createRustEngine();
|
||||
} catch (error) {
|
||||
if (isValidRuntimeDepName('@docmd/engine-rust')) {
|
||||
const installed = await installRuntimeDep('@docmd/engine-rust');
|
||||
if (installed) {
|
||||
const reloaded = await tryLoadAfterInstall('@docmd/engine-rust');
|
||||
if (reloaded) {
|
||||
const { createRustEngine, isRustEngineAvailable } = reloaded as any;
|
||||
if (isRustEngineAvailable && isRustEngineAvailable()) {
|
||||
return createRustEngine();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.warn(`[docmd] Rust engine unavailable (${(error as Error).message}), falling back to JS engine.`);
|
||||
return loadEngine('js');
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unknown engine: '${name}'. Available built-in engines: js, rust`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a named engine is available without loading it.
|
||||
*/
|
||||
export async function isEngineAvailable(name: string): Promise<boolean> {
|
||||
if (name === 'js') return true;
|
||||
if (name === 'rust') {
|
||||
try {
|
||||
const { isRustEngineAvailable } = await import('@docmd/engine-rust');
|
||||
return isRustEngineAvailable();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return engineRegistry.has(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return names of all currently available engines.
|
||||
*/
|
||||
export async function getAvailableEngines(): Promise<string[]> {
|
||||
const engines: string[] = ['js'];
|
||||
if (await isEngineAvailable('rust')) engines.push('rust');
|
||||
return engines;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Security Layer — plugin-facing task runner
|
||||
//
|
||||
// Plugins call these helpers rather than calling engine.run() directly.
|
||||
// The API layer validates the task type against the allowlist before
|
||||
// forwarding to the engine, preventing plugins from invoking arbitrary tasks.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Run a task on an engine.
|
||||
*
|
||||
* @param engine - The engine instance to use.
|
||||
* @param type - Task type (must be in the allowed set).
|
||||
* @param payload - Task payload.
|
||||
* @param timeout - Optional timeout in ms.
|
||||
* @param trusted - Internal flag: bypass the allowlist (used by docmd core only).
|
||||
* @throws if the task type is not allowed or the task fails.
|
||||
*/
|
||||
export async function runTask<T = any>(
|
||||
engine: Engine,
|
||||
type: string,
|
||||
payload: any,
|
||||
timeout?: number,
|
||||
trusted = false,
|
||||
): Promise<T> {
|
||||
if (!trusted && !ALLOWED_TASK_TYPES.has(type)) {
|
||||
throw new Error(
|
||||
`Task type '${type}' is not allowed for plugins. ` +
|
||||
`Allowed types: ${[...ALLOWED_TASK_TYPES].join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const result: EngineResult<T> = await engine.run<T>({ type, payload, timeout });
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || `Task '${type}' failed`);
|
||||
}
|
||||
return result.data as T;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Convenience Helpers (plugin-safe, validated against allowlist)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Discover files in a directory tree.
|
||||
*/
|
||||
export async function discoverFiles(
|
||||
engine: Engine,
|
||||
dir: string,
|
||||
extensions?: string[],
|
||||
exclude?: string[],
|
||||
): Promise<Array<{ path: string; size: number; mtimeMs: number }>> {
|
||||
return runTask(engine, 'file:discover', { dir, extensions, exclude });
|
||||
}
|
||||
|
||||
/**
|
||||
* Read multiple files in a single batch operation.
|
||||
* Returns a Map from file path to file content.
|
||||
*/
|
||||
export async function readFilesBatch(
|
||||
engine: Engine,
|
||||
paths: string[],
|
||||
): Promise<Map<string, string>> {
|
||||
const result = await runTask<Record<string, string>>(engine, 'file:readBatch', { paths });
|
||||
return new Map(Object.entries(result));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get git log for one or more files.
|
||||
* Returns a Map from file path to array of commit entries.
|
||||
*/
|
||||
export async function getGitLog(
|
||||
engine: Engine,
|
||||
filePaths: string[],
|
||||
maxCommits = 6,
|
||||
): Promise<Map<string, Array<{ hash: string; shortHash: string; author: string; email: string; timestamp: number; message: string }>>> {
|
||||
const result = await runTask<Record<string, any[]>>(engine, 'git:log', { filePaths, maxCommits });
|
||||
return new Map(Object.entries(result));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a search index from a list of documents.
|
||||
* Returns a serialised index string (format depends on the engine).
|
||||
*/
|
||||
export async function buildSearchIndex(
|
||||
engine: Engine,
|
||||
documents: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
path: string;
|
||||
locale?: string;
|
||||
version?: string;
|
||||
}>,
|
||||
): Promise<string> {
|
||||
return runTask(engine, 'search:index', { documents });
|
||||
}
|
||||
@@ -0,0 +1,975 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* @package @docmd/api
|
||||
* @website https://docmd.io
|
||||
* @repository https://github.com/docmd-io/docmd
|
||||
* @license MIT
|
||||
* @copyright Copyright (c) 2025-present docmd.io
|
||||
*
|
||||
* [docmd-source] - Please do not remove this header.
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Plugin loader with validation, isolation, and capability enforcement.
|
||||
*
|
||||
* - Lightweight contract check at load time.
|
||||
* - Every hook invocation wrapped in try/catch.
|
||||
* - Plugins can only register for hooks they've declared.
|
||||
*/
|
||||
|
||||
import { TUI } from '@docmd/tui';
|
||||
import path from 'node:path';
|
||||
import nativeFs from 'node:fs';
|
||||
import process from 'node:process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { safePath, asUserPath } from '@docmd/utils';
|
||||
import type { PluginDescriptor, PluginHooks, PluginModule, Capability, TemplateHook, TemplateAssetHook } from './types.js';
|
||||
import {
|
||||
loadRuntimeRegistry,
|
||||
installRuntimeDep,
|
||||
tryLoadAfterInstall,
|
||||
shortKey,
|
||||
} from './runtime-deps.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// Monorepo root - two levels up from packages/api/dist/
|
||||
const __monorepoRoot = path.resolve(__dirname, '..', '..', '..');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Capability → Hook mapping (§3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CAPABILITY_HOOKS: Record<Capability, string[]> = {
|
||||
markdown: ['markdownSetup'],
|
||||
head: ['generateMetaTags', 'generateScripts'],
|
||||
body: ['generateScripts'],
|
||||
assets: ['getAssets'],
|
||||
'post-build': ['onPostBuild'],
|
||||
actions: ['actions'],
|
||||
events: ['events'],
|
||||
translations: ['translations'],
|
||||
init: ['onConfigResolved'],
|
||||
build: ['onBeforeParse', 'onAfterParse', 'onBeforeBuild', 'onBeforeRender', 'onPageReady'],
|
||||
dev: ['onDevServerReady'],
|
||||
template: ['templates', 'templateAssets'],
|
||||
};
|
||||
|
||||
const KNOWN_CAPABILITIES = new Set(Object.keys(CAPABILITY_HOOKS));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const hooks: PluginHooks = {
|
||||
markdownSetup: [],
|
||||
injectHead: [],
|
||||
injectBody: [],
|
||||
onPostBuild: [],
|
||||
assets: [],
|
||||
translations: [],
|
||||
actions: {},
|
||||
events: {},
|
||||
onConfigResolved: [],
|
||||
onDevServerReady: [],
|
||||
onBeforeParse: [],
|
||||
onAfterParse: [],
|
||||
onBeforeBuild: [],
|
||||
onBeforeRender: [],
|
||||
onPageReady: [],
|
||||
templates: [],
|
||||
templateAssets: [],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation (§1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
function validateDescriptor(descriptor: any): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!descriptor || typeof descriptor !== 'object') {
|
||||
return { valid: false, errors: ['Missing plugin descriptor'] };
|
||||
}
|
||||
|
||||
if (!descriptor.name || typeof descriptor.name !== 'string') {
|
||||
errors.push('`name` must be a non-empty string');
|
||||
}
|
||||
|
||||
if (!descriptor.version || typeof descriptor.version !== 'string') {
|
||||
errors.push('`version` must be a valid semver string');
|
||||
}
|
||||
|
||||
if (!Array.isArray(descriptor.capabilities)) {
|
||||
errors.push('`capabilities` must be an array');
|
||||
} else {
|
||||
for (const cap of descriptor.capabilities) {
|
||||
if (!KNOWN_CAPABILITIES.has(cap)) {
|
||||
errors.push(`Unknown capability: "${cap}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a plugin has declared a capability that allows a specific hook.
|
||||
*/
|
||||
function hasCapabilityForHook(descriptor: PluginDescriptor | null, hookName: string): boolean {
|
||||
if (!descriptor) return true; // Legacy plugins without descriptors get full access
|
||||
for (const cap of descriptor.capabilities) {
|
||||
if (CAPABILITY_HOOKS[cap]?.includes(hookName)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Isolation wrapper (§2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function safeCall<T>(hookName: string, pluginName: string, fn: (...args: any[]) => T, ...args: any[]): Promise<T | string | undefined> {
|
||||
try {
|
||||
const result = fn(...args);
|
||||
// Plugins are allowed to return Promises (most `async` functions do).
|
||||
// We must await them before downstream code inspects the shape,
|
||||
// otherwise the dispatcher sees a Promise object (typeof 'object')
|
||||
// and silently drops the value through the wrong branch of
|
||||
// coerceStringPluginReturn / coerceGenerateScriptsReturn. This is a
|
||||
// longstanding bug — string-return hooks used to "work" only when
|
||||
// the plugin happened to be synchronous.
|
||||
if (result && typeof result === 'object' && typeof (result as any).then === 'function') {
|
||||
return await result;
|
||||
}
|
||||
return result;
|
||||
} catch (err: any) {
|
||||
TUI.error(`Plugin "${pluginName}" threw in ${hookName}`, err.message);
|
||||
return (hookName === 'injectHead' || hookName === 'injectBody') ? '' as any : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// One-shot warning set so we don't spam the TUI on every page build when a
|
||||
// plugin has a wrong return type. The key is the plugin name + the hook
|
||||
// name; the message is logged at most once per build per plugin/hook pair.
|
||||
const _pluginReturnTypeWarnings = new Set<string>();
|
||||
|
||||
function warnPluginReturnTypeOnce(pluginName: string, hookName: string, message: string): void {
|
||||
const key = `${pluginName}::${hookName}`;
|
||||
if (_pluginReturnTypeWarnings.has(key)) return;
|
||||
_pluginReturnTypeWarnings.has(key);
|
||||
_pluginReturnTypeWarnings.add(key);
|
||||
TUI.warn(` Plugin "${pluginName}" ${message}`);
|
||||
}
|
||||
|
||||
// D-S4: enforce that the returned value from a string-return hook is
|
||||
// actually a string. Reject objects (previously rendered as
|
||||
// "[object Object]"), booleans, numbers, etc. Null/undefined/empty-string
|
||||
// pass through as `''` so the surrounding `|| ''` collapses cleanly.
|
||||
function coerceStringPluginReturn(value: any, hookName: string, pluginName: string): string | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === 'string') return value;
|
||||
warnPluginReturnTypeOnce(
|
||||
pluginName,
|
||||
hookName,
|
||||
`returned a non-string value (${typeof value}); expected a string. Skipping.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// D-S5: accept either a plain string (treated as the `target` slot, body
|
||||
// stays empty) or the canonical `{ headScriptsHtml, bodyScriptsHtml }`
|
||||
// object. Strings are silently dropped on the body side per the original
|
||||
// contract; this just makes the head side honour the same convention.
|
||||
function coerceGenerateScriptsReturn(value: any, target: 'head' | 'body', pluginName: string): string | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === 'string') {
|
||||
// D-S5: a string return is treated as head-only. Body targets get
|
||||
// empty. This restores the head path that used to silently drop
|
||||
// strings via `result?.bodyScriptsHtml` and never wrote them either.
|
||||
return target === 'head' ? value : '';
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const key = target === 'head' ? 'headScriptsHtml' : 'bodyScriptsHtml';
|
||||
const v = value[key];
|
||||
return typeof v === 'string' ? v : null;
|
||||
}
|
||||
warnPluginReturnTypeOnce(
|
||||
pluginName,
|
||||
'generateScripts',
|
||||
`returned a non-object, non-string value (${typeof value}); expected a string or { headScriptsHtml, bodyScriptsHtml }. Skipping.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// D-M1: enforce that translations are a plain string-to-string map.
|
||||
// The previous behaviour silently spread a string return into the
|
||||
// translations object (`{ '0': 'c', '1': 'h', ... }`) which produced
|
||||
// garbage keys at runtime.
|
||||
function coerceTranslationsReturn(value: any, localeId: string, pluginName: string): Record<string, string> {
|
||||
if (value === null || value === undefined) return {};
|
||||
if (typeof value !== 'object' || Array.isArray(value)) {
|
||||
warnPluginReturnTypeOnce(
|
||||
pluginName,
|
||||
'translations',
|
||||
`returned a non-object value for locale "${localeId}" (${typeof value}); expected Record<string, string>. Skipping.`
|
||||
);
|
||||
return {};
|
||||
}
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
if (typeof v === 'string') out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const pluginErrors: { plugin: string; hook: string; message: string; filePath?: string }[] = [];
|
||||
|
||||
// separate tracker for load-time plugin failures (the
|
||||
// "unknown plugin" case from test-report §F6). RUNTIME hook errors go in
|
||||
// `pluginErrors`; LOAD failures (could not resolve / import) go here.
|
||||
// The build / dev commands check this via `getPluginLoadErrors()` and
|
||||
// exit 1 if any are present, so a missing plugin is a hard build failure
|
||||
// rather than a silent warning.
|
||||
const pluginLoadErrors: { plugin: string; message: string }[] = [];
|
||||
|
||||
export function getPluginErrors() {
|
||||
return pluginErrors;
|
||||
}
|
||||
|
||||
export function getPluginLoadErrors() {
|
||||
return pluginLoadErrors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical list of plugins that ship with @docmd/core. These are
|
||||
* auto-loaded on every build unless the user opts out via
|
||||
* `plugins.<name>: false` (or `enabled: false`) in their config.
|
||||
*
|
||||
* This is the single source of truth for the "core plugin set" across
|
||||
* the monorepo. Other packages (the installer, the docs site, etc.)
|
||||
* must import this constant rather than re-declaring the list —
|
||||
* re-declaring causes silent drift when a plugin is added/removed here.
|
||||
*
|
||||
* Adding a plugin: append the key to this array AND make sure the
|
||||
* plugin's @docmd/plugin-* package is listed as a workspace dep in
|
||||
* packages/core/package.json so it ships with @docmd/core on npm.
|
||||
*/
|
||||
export const CORE_PLUGINS: ReadonlyArray<string> = [
|
||||
'search', 'seo', 'sitemap', 'analytics', 'llms',
|
||||
'mermaid', 'git', 'openapi', 'okf'
|
||||
] as const;
|
||||
|
||||
/** True if `name` is one of the plugins that ship with @docmd/core. */
|
||||
export function isCorePlugin(name: string): boolean {
|
||||
return (CORE_PLUGINS as ReadonlyArray<string>).includes(name);
|
||||
}
|
||||
|
||||
// Track which plugin warnings have already been printed to avoid repeating them on
|
||||
// every dev-server rebuild. Keyed by `pluginName:warningType`.
|
||||
const _printedWarnings = new Set<string>();
|
||||
|
||||
function warnOnce(key: string, message: string): void {
|
||||
if (_printedWarnings.has(key)) return;
|
||||
_printedWarnings.add(key);
|
||||
TUI.warn(message);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shorthand resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function resolvePluginName(key: string): string {
|
||||
if (key.includes('/')) return key;
|
||||
|
||||
const registry = loadRuntimeRegistry();
|
||||
if (registry[key]) {
|
||||
return `@docmd/plugin-${key}`;
|
||||
}
|
||||
|
||||
const corePlugins = CORE_PLUGINS;
|
||||
if (corePlugins.includes(key)) {
|
||||
return `@docmd/plugin-${key}`;
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a template reference (from `config.theme.template` or frontmatter
|
||||
* `template:`) to its full npm package name. Mirrors `resolvePluginName`
|
||||
* but targets the `@docmd/template-*` scope.
|
||||
*
|
||||
* Accepts:
|
||||
* - `summer` → `@docmd/template-summer`
|
||||
* - `template-summer` → `@docmd/template-summer`
|
||||
* - `@docmd/template-summer` → `@docmd/template-summer` (unchanged)
|
||||
* - `@scope/template-summer` → `@scope/template-summer` (unchanged)
|
||||
* - `./relative/path` → unchanged
|
||||
*/
|
||||
export function resolveTemplateName(key: string): string {
|
||||
if (!key) return key;
|
||||
if (key.includes('/') || key.startsWith('.')) return key;
|
||||
if (key.startsWith('template-')) return `@docmd/${key}`;
|
||||
return `@docmd/template-${key}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auto-Install for Official Plugins
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The registry loader, package-manager detector, version pinner, and
|
||||
// `spawn`-based installer live in `./runtime-deps.ts`. This module
|
||||
// replaces the previous inline `execSync(\`pnpm add ${pkg}\`)` which
|
||||
// was a CWE-78 (shell injection) surface. The shared module is also
|
||||
// consumed by `engine.ts` for engine auto-install.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Load & Register
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function loadPlugins(config: any, opts?: { resolvePaths?: string[] }): Promise<PluginHooks> {
|
||||
// 1. Resolution paths for plugin imports - the caller (e.g. @docmd/core) should
|
||||
// pass its own __dirname so plugins that are core's dependencies can be found
|
||||
// even under pnpm's strict node_modules layout.
|
||||
const resolvePaths = [
|
||||
process.cwd(),
|
||||
__dirname,
|
||||
__monorepoRoot,
|
||||
path.join(__monorepoRoot, 'packages/plugins'),
|
||||
path.join(__monorepoRoot, 'packages/templates'),
|
||||
...(opts?.resolvePaths || [])
|
||||
];
|
||||
|
||||
// 1. Reset hooks
|
||||
hooks.markdownSetup = [];
|
||||
hooks.injectHead = [];
|
||||
hooks.injectBody = [];
|
||||
hooks.onPostBuild = [];
|
||||
hooks.assets = [];
|
||||
hooks.translations = [];
|
||||
hooks.actions = {};
|
||||
hooks.events = {};
|
||||
hooks.onConfigResolved = [];
|
||||
hooks.onDevServerReady = [];
|
||||
hooks.onBeforeParse = [];
|
||||
hooks.onAfterParse = [];
|
||||
hooks.onBeforeBuild = [];
|
||||
hooks.onBeforeRender = [];
|
||||
hooks.onPageReady = [];
|
||||
hooks.templates = [];
|
||||
hooks.templateAssets = [];
|
||||
pluginErrors.length = 0;
|
||||
pluginLoadErrors.length = 0;
|
||||
|
||||
// 2. Initialize Plugin Map (Name -> Options)
|
||||
const pluginMap = new Map<string, any>();
|
||||
const searchEnabled = config.optionsMenu ? config.optionsMenu.components.search !== false : config.search !== false;
|
||||
|
||||
// A. Core Plugins - always loaded by default.
|
||||
// 0.8.8: added `okf` (Open Knowledge Format bundles for AI agents).
|
||||
// `okf` follows the same pattern as `llms` — auto-loaded, opt-out via
|
||||
// `plugins.okf = false` in the user's config.
|
||||
const corePlugins = CORE_PLUGINS;
|
||||
|
||||
for (const name of corePlugins) {
|
||||
const resolved = `@docmd/plugin-${name}`;
|
||||
const userOpts = config.plugins?.[name];
|
||||
|
||||
if (userOpts === false || (userOpts && userOpts.enabled === false)) {
|
||||
pluginMap.set(resolved, false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (name === 'search' && !searchEnabled) {
|
||||
pluginMap.set(resolved, false);
|
||||
continue;
|
||||
}
|
||||
|
||||
pluginMap.set(resolved, userOpts || {});
|
||||
}
|
||||
|
||||
// B. Add/Override from Config (non-core / optional / third-party plugins)
|
||||
if (config.plugins) {
|
||||
Object.keys(config.plugins).forEach(key => {
|
||||
const resolvedName = resolvePluginName(key);
|
||||
if (corePlugins.includes(key)) return;
|
||||
pluginMap.set(resolvedName, config.plugins[key]);
|
||||
});
|
||||
}
|
||||
|
||||
// B'. Auto-include the active template (new in 0.8.7)
|
||||
// If `config.theme.template` is set, make sure the corresponding
|
||||
// `@docmd/template-*` package is in the plugin map. The user does not
|
||||
// need to also list it in `config.plugins`. Explicit user entries win.
|
||||
if (config.theme && config.theme.template) {
|
||||
const tplName = String(config.theme.template).trim();
|
||||
if (tplName) {
|
||||
const resolvedTemplate = resolveTemplateName(tplName);
|
||||
// Only add if not already present (user might have set explicit options).
|
||||
if (!pluginMap.has(resolvedTemplate)) {
|
||||
pluginMap.set(resolvedTemplate, {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Load and Register (with auto-install for official plugins)
|
||||
for (const [name, options] of pluginMap) {
|
||||
if (options === false) continue;
|
||||
|
||||
try {
|
||||
let rawModule: any;
|
||||
let needsAutoInstall = false;
|
||||
const isLocalPath = name.startsWith('./') || name.startsWith('../') || name.startsWith('/');
|
||||
|
||||
try {
|
||||
let loadedFromMonorepo = false;
|
||||
|
||||
// 1. Monorepo Priority: if it's an official plugin OR template, try local
|
||||
// monorepo source first. This prevents older versions installed in project
|
||||
// node_modules from taking precedence during monorepo development.
|
||||
if (name.startsWith('@docmd/plugin-')) {
|
||||
const id = name.replace('@docmd/plugin-', '');
|
||||
const localPath = path.resolve(__monorepoRoot, 'packages/plugins', id, 'dist/index.js');
|
||||
if (nativeFs.existsSync(localPath)) {
|
||||
rawModule = await import(pathToFileURL(localPath).href);
|
||||
loadedFromMonorepo = true;
|
||||
}
|
||||
} else if (name.startsWith('@docmd/template-')) {
|
||||
// Templates live under packages/templates/<name>/ in the monorepo.
|
||||
const id = name.replace('@docmd/template-', '');
|
||||
const localPath = path.resolve(__monorepoRoot, 'packages/templates', id, 'dist/index.js');
|
||||
if (nativeFs.existsSync(localPath)) {
|
||||
rawModule = await import(pathToFileURL(localPath).href);
|
||||
loadedFromMonorepo = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Standard NPM Resolution: if not found locally, use Node's resolution
|
||||
if (!loadedFromMonorepo) {
|
||||
let resolvedPath: string;
|
||||
if (isLocalPath) {
|
||||
// Phase 1.A: CWE-22/CWE-94 fix (T-S8). Local-path plugins must resolve
|
||||
// inside the project root. Without this, require.resolve(name, { paths })
|
||||
// can search parent directories and load arbitrary plugins.
|
||||
const projectRoot = path.resolve(process.cwd());
|
||||
try {
|
||||
resolvedPath = safePath(projectRoot, asUserPath(name));
|
||||
} catch (_e: any) {
|
||||
throw new Error(`Local plugin path "${name}" escapes project root`);
|
||||
}
|
||||
// Resolve directory imports to the package's main field (or index.js).
|
||||
// Node ESM does not support bare directory imports.
|
||||
try {
|
||||
const stat = nativeFs.statSync(resolvedPath);
|
||||
if (stat.isDirectory()) {
|
||||
const pkgPath = path.join(resolvedPath, 'package.json');
|
||||
if (nativeFs.existsSync(pkgPath)) {
|
||||
const pkg = JSON.parse(nativeFs.readFileSync(pkgPath, 'utf8'));
|
||||
const main = (pkg.main || 'index.js').replace(/^\.\//, '');
|
||||
resolvedPath = path.join(resolvedPath, main);
|
||||
} else if (nativeFs.existsSync(path.join(resolvedPath, 'index.js'))) {
|
||||
resolvedPath = path.join(resolvedPath, 'index.js');
|
||||
}
|
||||
}
|
||||
} catch (_e: any) {
|
||||
throw new Error(`Local plugin directory "${name}" has no resolvable entry point: ${_e.message}`);
|
||||
}
|
||||
} else {
|
||||
resolvedPath = require.resolve(name, { paths: resolvePaths });
|
||||
}
|
||||
rawModule = await import(pathToFileURL(resolvedPath).href);
|
||||
}
|
||||
} catch (_e: any) {
|
||||
if (name.startsWith('@docmd/plugin-') || name.startsWith('@docmd/template-')) {
|
||||
needsAutoInstall = true;
|
||||
} else if (isLocalPath) {
|
||||
// Phase 1.A: a local-path plugin that fails safePath or import must
|
||||
// be reported with the original safety error, not swallowed into the
|
||||
// generic "Failed to resolve" fallback.
|
||||
throw _e;
|
||||
} else {
|
||||
// Fallback for non-package plugins or when resolution fails
|
||||
try {
|
||||
rawModule = await import(name);
|
||||
} catch (innerError: any) {
|
||||
throw new Error(`Failed to resolve ${name}. Search paths: ${resolvePaths.join(', ')}. Detail: ${innerError.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-install official plugins AND templates that are missing
|
||||
const isOfficial = name.startsWith('@docmd/plugin-') || name.startsWith('@docmd/template-');
|
||||
if (needsAutoInstall && isOfficial) {
|
||||
const installed = await installRuntimeDep(name);
|
||||
if (installed) {
|
||||
// Defense in depth: re-verify the package is in the official registry
|
||||
// before loading. installRuntimeDep already passed this check, but we
|
||||
// re-check here so a future change to that function cannot silently
|
||||
// turn the auto-install path into a generic npm-loader.
|
||||
const shortNameLocal = shortKey(name);
|
||||
const registry = loadRuntimeRegistry();
|
||||
if (!shortNameLocal || !registry[shortNameLocal]) {
|
||||
warnOnce(`registry:${name}`, TUI.yellow(`Plugin "${shortNameLocal ?? name}" not in official registry`));
|
||||
continue;
|
||||
}
|
||||
// Retry loading after install. We use dynamic `import()` (not
|
||||
// `require.resolve` + file:// import) so packages that declare
|
||||
// `exports` with only an `import` condition are still resolvable.
|
||||
const reloaded = await tryLoadAfterInstall(name);
|
||||
if (!reloaded) {
|
||||
warnOnce(
|
||||
`autoinstall:${name}`,
|
||||
TUI.yellow(`Could not load ${name} after auto-install`),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
rawModule = reloaded;
|
||||
} else {
|
||||
continue; // Skip if auto-install failed
|
||||
}
|
||||
}
|
||||
|
||||
if (!rawModule) continue;
|
||||
|
||||
const pluginModule: PluginModule = rawModule.default || rawModule;
|
||||
|
||||
// Stage 4: pull the manifest's declared capabilities (from the
|
||||
// registry) so registerPlugin can cross-check the JS descriptor.
|
||||
const shortNameForManifest = shortKey(name) ?? name;
|
||||
const manifestCapabilities = (loadRuntimeRegistry()[shortNameForManifest]?.capabilities) as string[] | undefined;
|
||||
|
||||
try {
|
||||
registerPlugin(name, pluginModule, options, manifestCapabilities);
|
||||
} catch (regError: any) {
|
||||
warnOnce(`register:${name}`, TUI.yellow(`Plugin loaded but failed to register: ${name}`) + TUI.dim(`\n > ${regError.message}`));
|
||||
}
|
||||
} catch (e: any) {
|
||||
warnOnce(`load:${name}`, TUI.yellow(`Could not load plugin: ${name} (missing or misconfigured)`) + TUI.dim(`\n > ${e.message}`));
|
||||
// track load failures so the build can fail
|
||||
// loudly instead of silently completing with a missing plugin.
|
||||
pluginLoadErrors.push({ plugin: name, message: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Print error summary if any
|
||||
if (pluginErrors.length > 0) {
|
||||
TUI.warn(`${pluginErrors.length} plugin error(s) occurred (build completed)`);
|
||||
}
|
||||
|
||||
return hooks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached per-key capability sets, derived from the registry once and
|
||||
* reused on every dev-server rebuild. Avoids re-walking the registry
|
||||
* (and the re-`Set` construction) on every hot reload.
|
||||
*/
|
||||
const _capabilityCache: Map<string, Set<string>> = new Map();
|
||||
|
||||
/** Get or build a capability Set for a registry key. */
|
||||
function getCapabilitySet(shortName: string): Set<string> {
|
||||
let set = _capabilityCache.get(shortName);
|
||||
if (set) return set;
|
||||
const entry = loadRuntimeRegistry()[shortName];
|
||||
set = new Set<string>(Array.isArray(entry?.capabilities) ? entry.capabilities : []);
|
||||
_capabilityCache.set(shortName, set);
|
||||
return set;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-check the JS descriptor's `capabilities` against the manifest's
|
||||
* `docmd.capabilities` (from the generated registry). Catches:
|
||||
* - Descriptor declares a capability the manifest doesn't (drift).
|
||||
* - Manifest declares a capability the descriptor doesn't (drift).
|
||||
* - Implemented hook without declared capability (the silent-drop bug).
|
||||
*
|
||||
* Not a hard error — capabilities are advisory metadata. But loud
|
||||
* warnings surface drift before it causes a build regression.
|
||||
*/
|
||||
function checkManifestCapabilityDrift(
|
||||
shortName: string,
|
||||
descriptor: PluginDescriptor | null,
|
||||
plugin: PluginModule,
|
||||
): string[] {
|
||||
const warnings: string[] = [];
|
||||
const manifestCaps = Array.from(getCapabilitySet(shortName));
|
||||
if (manifestCaps.length === 0) return warnings; // Third-party or unknown package; skip.
|
||||
if (!descriptor) return warnings; // Legacy; skip.
|
||||
|
||||
const descCaps = new Set<string>(Array.isArray(descriptor.capabilities) ? (descriptor.capabilities as string[]) : []);
|
||||
|
||||
// Implemented hooks vs declared capabilities.
|
||||
const KNOWN_HOOKS_BY_CAP: Array<[string, keyof PluginModule]> = [
|
||||
['markdown', 'markdownSetup'],
|
||||
['head', 'generateMetaTags'],
|
||||
['body', 'generateScripts'],
|
||||
['post-build', 'onPostBuild'],
|
||||
['assets', 'getAssets'],
|
||||
['actions', 'actions'],
|
||||
['events', 'events'],
|
||||
['translations', 'translations'],
|
||||
];
|
||||
for (const [cap, hook] of KNOWN_HOOKS_BY_CAP) {
|
||||
if (typeof plugin[hook] === 'function' && !descCaps.has(cap) && !manifestCaps.includes(cap)) {
|
||||
warnings.push(
|
||||
`exports \`${hook}\` but neither the descriptor nor the manifest ` +
|
||||
`declares the "${cap}" capability. The hook will be registered.`
|
||||
);
|
||||
}
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
function registerPlugin(
|
||||
name: string,
|
||||
plugin: PluginModule,
|
||||
options: any,
|
||||
manifestCapabilities?: string[], // Stage 4: from registry entry, for cross-check
|
||||
) {
|
||||
const shortName = name.replace(/^@docmd\/plugin-/, '').replace(/^@docmd\/template-/, '');
|
||||
const isOfficial = name.startsWith('@docmd/plugin-') || name.startsWith('@docmd/template-');
|
||||
|
||||
// --- §1: Validate descriptor ---
|
||||
const descriptor = plugin.plugin || null;
|
||||
|
||||
if (descriptor) {
|
||||
const { valid, errors } = validateDescriptor(descriptor);
|
||||
if (!valid) {
|
||||
const msg = `Plugin "${name}" descriptor failed validation: ${errors.join(', ')}`;
|
||||
if (isOfficial) {
|
||||
throw new Error(msg); // Hard error for official plugins
|
||||
}
|
||||
TUI.warn(`${msg} - registering anyway`);
|
||||
}
|
||||
} else {
|
||||
// No descriptor - emit deprecation warning (soft until 0.8.0)
|
||||
// Silent for official plugins as they'll be updated together
|
||||
if (!isOfficial) {
|
||||
TUI.warn(`Plugin "${name}" has no descriptor. This will be required in 0.8.0.`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- §1.5: Manifest / descriptor capability drift (Stage 4) ---
|
||||
if (manifestCapabilities) {
|
||||
const drift = checkManifestCapabilityDrift(shortName, descriptor, plugin);
|
||||
for (const w of drift) {
|
||||
TUI.warn(`Plugin "${shortName}": ${w}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- §3: Capability-gated registration ---
|
||||
const shouldExecute = (pageContext: any) => {
|
||||
if (!pageContext || !pageContext.frontmatter) return true;
|
||||
const fmPlugins = pageContext.frontmatter.plugins || {};
|
||||
|
||||
if (fmPlugins[shortName] === false) return false;
|
||||
if (fmPlugins[shortName] === true) return true;
|
||||
|
||||
if (pageContext.frontmatter.noStyle) {
|
||||
if (options && options.noStyle !== undefined) return options.noStyle;
|
||||
if (plugin.noStyle !== undefined) return plugin.noStyle;
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// markdownSetup
|
||||
if (typeof plugin.markdownSetup === 'function') {
|
||||
if (hasCapabilityForHook(descriptor, 'markdownSetup')) {
|
||||
const fn = plugin.markdownSetup;
|
||||
hooks.markdownSetup.push((md: any) => safeCall('markdownSetup', name, fn, md, options));
|
||||
} else {
|
||||
TUI.warn(`Plugin "${shortName}" exports markdownSetup but didn't declare "markdown" capability - skipped`);
|
||||
}
|
||||
}
|
||||
|
||||
// generateMetaTags → injectHead
|
||||
if (typeof plugin.generateMetaTags === 'function') {
|
||||
if (hasCapabilityForHook(descriptor, 'generateMetaTags')) {
|
||||
const fn = plugin.generateMetaTags;
|
||||
hooks.injectHead.push(async (config: any, pageContext: any, root: any) => {
|
||||
if (!shouldExecute(pageContext)) return '';
|
||||
const raw = await safeCall('generateMetaTags', name, fn, config, pageContext, root);
|
||||
// D-S4: the contract is `string`. Object returns used to be
|
||||
// stringified to "[object Object]" and injected into every page's
|
||||
// <head>. We now reject the wrong shape, warn, and skip — same
|
||||
// behaviour we use for missing/null returns.
|
||||
return coerceStringPluginReturn(raw, 'generateMetaTags', name) || '';
|
||||
});
|
||||
} else {
|
||||
TUI.warn(`Plugin "${shortName}" exports generateMetaTags but didn't declare "head" capability - skipped`);
|
||||
}
|
||||
}
|
||||
|
||||
// generateScripts → injectHead + injectBody
|
||||
if (typeof plugin.generateScripts === 'function') {
|
||||
if (hasCapabilityForHook(descriptor, 'generateScripts')) {
|
||||
const fn = plugin.generateScripts;
|
||||
// D-H3: pass a `target` arg so plugins can render different content
|
||||
// for head vs body without computing both. The arg is optional —
|
||||
// existing plugins that only know `(config, options)` still work.
|
||||
// D-S5: plain string returns are now treated as the head slot;
|
||||
// body gets an empty string. Previously a string return was dropped
|
||||
// on the body side (result?.bodyScriptsHtml was undefined), making
|
||||
// the body capability effectively dead for the simple shape.
|
||||
hooks.injectHead.push(async (config: any, pageContext: any) => {
|
||||
if (!shouldExecute(pageContext)) return '';
|
||||
const raw = await safeCall('generateScripts', name, fn, config, options, 'head') as any;
|
||||
return coerceGenerateScriptsReturn(raw, 'head', name) || '';
|
||||
});
|
||||
hooks.injectBody.push(async (config: any, pageContext: any) => {
|
||||
if (!shouldExecute(pageContext)) return '';
|
||||
const raw = await safeCall('generateScripts', name, fn, config, options, 'body') as any;
|
||||
return coerceGenerateScriptsReturn(raw, 'body', name) || '';
|
||||
});
|
||||
} else {
|
||||
TUI.warn(`Plugin "${shortName}" exports generateScripts but didn't declare "head"/"body" capability - skipped`);
|
||||
}
|
||||
}
|
||||
|
||||
// onPostBuild
|
||||
if (typeof plugin.onPostBuild === 'function') {
|
||||
if (hasCapabilityForHook(descriptor, 'onPostBuild')) {
|
||||
const fn = plugin.onPostBuild;
|
||||
const wrapper = async (ctx: any) => {
|
||||
try {
|
||||
await fn(ctx);
|
||||
} catch (err: any) {
|
||||
TUI.error(`Plugin "${name}" threw in onPostBuild`, err.message);
|
||||
pluginErrors.push({ plugin: name, hook: 'onPostBuild', message: err.message });
|
||||
}
|
||||
};
|
||||
// Tag with the plugin's own declared name (e.g. 'search', 'sitemap')
|
||||
// so build.ts can split hooks into indexing vs publishing phases.
|
||||
(wrapper as any)._pluginName = descriptor?.name || shortName;
|
||||
hooks.onPostBuild.push(wrapper);
|
||||
} else {
|
||||
TUI.warn(`Plugin "${shortName}" exports onPostBuild but didn't declare "post-build" capability - skipped`);
|
||||
}
|
||||
}
|
||||
|
||||
// getAssets
|
||||
if (typeof plugin.getAssets === 'function') {
|
||||
if (hasCapabilityForHook(descriptor, 'getAssets')) {
|
||||
const fn = plugin.getAssets;
|
||||
hooks.assets.push(async () => (await safeCall('getAssets', name, fn, options)) as any[] || []);
|
||||
} else {
|
||||
TUI.warn(`Plugin "${shortName}" exports getAssets but didn't declare "assets" capability - skipped`);
|
||||
}
|
||||
}
|
||||
|
||||
// templates (new in 0.8.7)
|
||||
// A plugin can ship a `templates: TemplateHook[]` array listing the EJS
|
||||
// files it overrides. The resolver in @docmd/ui merges these with the
|
||||
// default templates, falling back to the default for any slot the plugin
|
||||
// does not provide. The plugin descriptor MUST declare the `template`
|
||||
// capability for the entries to register.
|
||||
if (Array.isArray((plugin as any).templates)) {
|
||||
if (hasCapabilityForHook(descriptor, 'templates')) {
|
||||
for (const tpl of (plugin as any).templates as TemplateHook[]) {
|
||||
if (!tpl || !tpl.type || !tpl.templatePath) {
|
||||
TUI.warn(`Plugin "${shortName}" provides a malformed templates[] entry (needs { type, templatePath }) - skipped`);
|
||||
continue;
|
||||
}
|
||||
// Attach plugin name for diagnostics & resolution.
|
||||
(tpl as any)._pluginName = descriptor?.name || shortName;
|
||||
hooks.templates.push(tpl);
|
||||
}
|
||||
} else {
|
||||
TUI.warn(`Plugin "${shortName}" exports templates but didn't declare "template" capability - skipped`);
|
||||
}
|
||||
}
|
||||
|
||||
// templateAssets (new in 0.8.7)
|
||||
// CSS/JS bundles shipped by a template. Loaded at priority 10 by default
|
||||
// so user customCss (priority 15) still wins.
|
||||
if (Array.isArray((plugin as any).templateAssets)) {
|
||||
if (hasCapabilityForHook(descriptor, 'templateAssets')) {
|
||||
for (const asset of (plugin as any).templateAssets as TemplateAssetHook[]) {
|
||||
if (!asset || !asset.type || !asset.path) {
|
||||
TUI.warn(`Plugin "${shortName}" provides a malformed templateAssets[] entry (needs { type, path }) - skipped`);
|
||||
continue;
|
||||
}
|
||||
// Normalise: never mutate the caller's object.
|
||||
hooks.templateAssets.push({
|
||||
type: asset.type,
|
||||
path: asset.path,
|
||||
priority: asset.priority !== undefined ? asset.priority : 10,
|
||||
position: asset.position,
|
||||
_pluginName: descriptor?.name || shortName,
|
||||
} as TemplateAssetHook);
|
||||
}
|
||||
} else {
|
||||
TUI.warn(`Plugin "${shortName}" exports templateAssets but didn't declare "template" capability - skipped`);
|
||||
}
|
||||
}
|
||||
|
||||
// translations
|
||||
if (typeof plugin.translations === 'function') {
|
||||
if (hasCapabilityForHook(descriptor, 'translations')) {
|
||||
const fn = plugin.translations;
|
||||
hooks.translations.push(async (localeId: string) => {
|
||||
// D-M1 + async-await fix: the callback is async because plugins
|
||||
// are allowed to return Promises. `safeCall` itself awaits
|
||||
// before returning, so `raw` is always a settled value here.
|
||||
const raw = await safeCall('translations', name, fn, localeId, options);
|
||||
return coerceTranslationsReturn(raw, localeId, name);
|
||||
});
|
||||
} else {
|
||||
TUI.warn(`Plugin "${shortName}" exports translations but didn't declare "translations" capability - skipped`);
|
||||
}
|
||||
}
|
||||
|
||||
// actions (WebSocket RPC)
|
||||
if (plugin.actions && typeof plugin.actions === 'object') {
|
||||
if (hasCapabilityForHook(descriptor, 'actions')) {
|
||||
Object.assign(hooks.actions, plugin.actions);
|
||||
} else {
|
||||
TUI.warn(`Plugin "${shortName}" exports actions but didn't declare "actions" capability - skipped`);
|
||||
}
|
||||
}
|
||||
|
||||
// events (fire-and-forget)
|
||||
if (plugin.events && typeof plugin.events === 'object') {
|
||||
if (hasCapabilityForHook(descriptor, 'events')) {
|
||||
Object.assign(hooks.events, plugin.events);
|
||||
} else {
|
||||
TUI.warn(`Plugin "${shortName}" exports events but didn't declare "events" capability - skipped`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Expanded Lifecycle Hooks ---
|
||||
|
||||
// onConfigResolved
|
||||
if (typeof plugin.onConfigResolved === 'function') {
|
||||
if (hasCapabilityForHook(descriptor, 'onConfigResolved')) {
|
||||
const fn = plugin.onConfigResolved;
|
||||
hooks.onConfigResolved.push(async (config: any) => {
|
||||
try {
|
||||
await fn(config);
|
||||
} catch (err: any) {
|
||||
TUI.error(`Plugin "${name}" threw in onConfigResolved`, err.message);
|
||||
pluginErrors.push({ plugin: name, hook: 'onConfigResolved', message: err.message });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
TUI.warn(`Plugin "${shortName}" exports onConfigResolved but didn't declare "init" capability - skipped`);
|
||||
}
|
||||
}
|
||||
|
||||
// onDevServerReady
|
||||
if (typeof plugin.onDevServerReady === 'function') {
|
||||
if (hasCapabilityForHook(descriptor, 'onDevServerReady')) {
|
||||
const fn = plugin.onDevServerReady;
|
||||
hooks.onDevServerReady.push(async (server: any, wss: any) => {
|
||||
try {
|
||||
await fn(server, wss);
|
||||
} catch (err: any) {
|
||||
TUI.error(`Plugin "${name}" threw in onDevServerReady`, err.message);
|
||||
pluginErrors.push({ plugin: name, hook: 'onDevServerReady', message: err.message });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
TUI.warn(`Plugin "${shortName}" exports onDevServerReady but didn't declare "dev" capability - skipped`);
|
||||
}
|
||||
}
|
||||
|
||||
// onBeforeParse
|
||||
if (typeof plugin.onBeforeParse === 'function') {
|
||||
if (hasCapabilityForHook(descriptor, 'onBeforeParse')) {
|
||||
const fn = plugin.onBeforeParse;
|
||||
hooks.onBeforeParse.push(async (src: string, frontmatter: any, filePath?: string) => {
|
||||
try {
|
||||
return await fn(src, frontmatter, filePath) ?? src;
|
||||
} catch (err: any) {
|
||||
const loc = filePath ? ` in ${filePath}` : '';
|
||||
TUI.error(`Plugin "${name}" threw in onBeforeParse${loc}`, err.message);
|
||||
pluginErrors.push({ plugin: name, hook: 'onBeforeParse', message: err.message, filePath });
|
||||
return src;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
TUI.warn(`Plugin "${shortName}" exports onBeforeParse but didn't declare "build" capability - skipped`);
|
||||
}
|
||||
}
|
||||
|
||||
// onAfterParse
|
||||
if (typeof plugin.onAfterParse === 'function') {
|
||||
if (hasCapabilityForHook(descriptor, 'onAfterParse')) {
|
||||
const fn = plugin.onAfterParse;
|
||||
hooks.onAfterParse.push(async (html: string, frontmatter: any, filePath?: string) => {
|
||||
try {
|
||||
return await fn(html, frontmatter, filePath) ?? html;
|
||||
} catch (err: any) {
|
||||
const loc = filePath ? ` in ${filePath}` : '';
|
||||
TUI.error(`Plugin "${name}" threw in onAfterParse${loc}`, err.message);
|
||||
pluginErrors.push({ plugin: name, hook: 'onAfterParse', message: err.message, filePath });
|
||||
return html;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
TUI.warn(`Plugin "${shortName}" exports onAfterParse but didn't declare "build" capability - skipped`);
|
||||
}
|
||||
}
|
||||
|
||||
// onBeforeBuild
|
||||
if (typeof (plugin as any).onBeforeBuild === 'function') {
|
||||
if (hasCapabilityForHook(descriptor, 'onBeforeBuild')) {
|
||||
const fn = (plugin as any).onBeforeBuild;
|
||||
hooks.onBeforeBuild.push(async (ctx: any) => {
|
||||
try {
|
||||
await fn(ctx);
|
||||
} catch (err: any) {
|
||||
TUI.error(`Plugin "${name}" threw in onBeforeBuild`, err.message);
|
||||
pluginErrors.push({ plugin: name, hook: 'onBeforeBuild', message: err.message });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
TUI.warn(`Plugin "${shortName}" exports onBeforeBuild but didn't declare "build" capability - skipped`);
|
||||
}
|
||||
}
|
||||
|
||||
// onBeforeRender
|
||||
if (typeof (plugin as any).onBeforeRender === 'function') {
|
||||
if (hasCapabilityForHook(descriptor, 'onBeforeRender')) {
|
||||
const fn = (plugin as any).onBeforeRender;
|
||||
hooks.onBeforeRender.push(async (page: any) => {
|
||||
try {
|
||||
await fn(page);
|
||||
} catch (err: any) {
|
||||
TUI.error(`Plugin "${name}" threw in onBeforeRender`, err.message);
|
||||
pluginErrors.push({ plugin: name, hook: 'onBeforeRender', message: err.message });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
TUI.warn(`Plugin "${shortName}" exports onBeforeRender but didn't declare "build" capability - skipped`);
|
||||
}
|
||||
}
|
||||
|
||||
// onPageReady
|
||||
if (typeof plugin.onPageReady === 'function') {
|
||||
if (hasCapabilityForHook(descriptor, 'onPageReady')) {
|
||||
const fn = plugin.onPageReady;
|
||||
hooks.onPageReady.push(async (page: any) => {
|
||||
try {
|
||||
await fn(page);
|
||||
} catch (err: any) {
|
||||
TUI.error(`Plugin "${name}" threw in onPageReady`, err.message);
|
||||
pluginErrors.push({ plugin: name, hook: 'onPageReady', message: err.message });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
TUI.warn(`Plugin "${shortName}" exports onPageReady but didn't declare "build" capability - skipped`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* @package @docmd/core (and ecosystem)
|
||||
* @website https://docmd.io
|
||||
* @repository https://github.com/docmd-io/docmd
|
||||
* @license MIT
|
||||
* @copyright Copyright (c) 2025-present docmd.io
|
||||
*
|
||||
* [docmd-source] - Please do not remove this header.
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
// Plugin loader & hook registry
|
||||
export { loadPlugins, hooks, resolvePluginName, resolveTemplateName, getPluginErrors, getPluginLoadErrors, CORE_PLUGINS, isCorePlugin } from './hooks.js';
|
||||
|
||||
// Runtime dependency bootstrap (auto-install pipeline shared by hooks + engines)
|
||||
export {
|
||||
loadRuntimeRegistry,
|
||||
detectPackageManager,
|
||||
getDocmdVersion,
|
||||
isValidRuntimeDepName,
|
||||
installRuntimeDep,
|
||||
tryLoadAfterInstall,
|
||||
shortKey as shortRuntimeDepKey,
|
||||
getBuildStatusReporter,
|
||||
} from './runtime-deps.js';
|
||||
|
||||
// RPC action/event dispatcher
|
||||
export { createActionDispatcher } from './rpc.js';
|
||||
|
||||
// Path safety helper — canonical implementation lives in @docmd/utils.
|
||||
// Re-exported here for backward compatibility with existing plugin imports.
|
||||
export { safePath } from '@docmd/utils';
|
||||
|
||||
// Source editing tools
|
||||
export { createSourceTools } from './source.js';
|
||||
|
||||
// TUI tools
|
||||
export { TUI } from '@docmd/tui';
|
||||
|
||||
// ─── Centralised URL Utilities ─────────────────────────────────────────────
|
||||
// Re-exported from @docmd/parser for plugin consumption.
|
||||
// Plugins MUST use these instead of rolling their own URL logic.
|
||||
export {
|
||||
sanitizeUrl,
|
||||
outputPathToSlug,
|
||||
outputPathToPathname,
|
||||
outputPathToCanonical,
|
||||
buildContextualUrl,
|
||||
createUrlContext,
|
||||
computePageUrls,
|
||||
buildAbsoluteUrl,
|
||||
resolveHref,
|
||||
normalizeInternalHref,
|
||||
} from '@docmd/parser';
|
||||
|
||||
export type { UrlContext, PageUrls } from '@docmd/parser';
|
||||
|
||||
// ─── Engine System ─────────────────────────────────────────────────────────
|
||||
// Re-exported from engine module.
|
||||
export {
|
||||
engineRegistry,
|
||||
registerEngine,
|
||||
loadEngine,
|
||||
isEngineAvailable,
|
||||
getAvailableEngines,
|
||||
runTask,
|
||||
discoverFiles,
|
||||
readFilesBatch,
|
||||
getGitLog,
|
||||
buildSearchIndex,
|
||||
} from './engine.js';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
// Plugin system
|
||||
PluginDescriptor,
|
||||
PluginModule,
|
||||
PluginHooks,
|
||||
Capability,
|
||||
PageContext,
|
||||
PostBuildContext,
|
||||
// Action/Event system
|
||||
ActionContext,
|
||||
ActionHandler,
|
||||
EventHandler,
|
||||
DispatchResult,
|
||||
// Source tools
|
||||
SourceTools,
|
||||
BlockInfo,
|
||||
InlineSegment,
|
||||
TextLocation,
|
||||
// Engine system
|
||||
Engine,
|
||||
EngineTask,
|
||||
EngineResult,
|
||||
EngineLoader,
|
||||
EngineInitOptions,
|
||||
BuiltinTaskType,
|
||||
FileDiscoverPayload,
|
||||
FileReadBatchPayload,
|
||||
GitLogPayload,
|
||||
SearchIndexPayload,
|
||||
// Assets
|
||||
Asset,
|
||||
AssetCondition,
|
||||
AssetKind,
|
||||
AssetPosition,
|
||||
// Template system
|
||||
TemplateSlot,
|
||||
TemplateHook,
|
||||
TemplateAssetHook,
|
||||
TemplateResolutionContext,
|
||||
ResolvedTemplate,
|
||||
} from './types.js';
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* @package @docmd/api
|
||||
* @website https://docmd.io
|
||||
* @repository https://github.com/docmd-io/docmd
|
||||
* @license MIT
|
||||
* @copyright Copyright (c) 2025-present docmd.io
|
||||
*
|
||||
* [docmd-source] - Please do not remove this header.
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Action dispatcher for live-edit WebSocket message handling.
|
||||
*
|
||||
* Routes incoming `call` messages to plugin action handlers and `event`
|
||||
* messages to plugin event handlers. Each call gets a fresh context with
|
||||
* file I/O helpers and source editing tools. Tracks modifications so the
|
||||
* caller knows whether a browser reload is needed.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import { createSourceTools } from './source.js';
|
||||
import { TUI } from '@docmd/tui';
|
||||
import { safePath } from '@docmd/utils';
|
||||
import type {
|
||||
ActionContext,
|
||||
ActionHandler,
|
||||
EventHandler,
|
||||
DispatchResult,
|
||||
} from './types.js';
|
||||
|
||||
interface DispatcherHooks {
|
||||
actions: Record<string, ActionHandler>;
|
||||
events: Record<string, EventHandler>;
|
||||
}
|
||||
|
||||
interface DispatcherOptions {
|
||||
projectRoot: string;
|
||||
config: any;
|
||||
broadcast: (event: string, data: any) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an action dispatcher bound to the given hooks and project context.
|
||||
*
|
||||
* @param hooks - `{ actions: {name: handler}, events: {name: handler} }`
|
||||
* @param options - Project root, config, and broadcast function
|
||||
* @returns `{ handleCall, handleEvent }`
|
||||
*/
|
||||
export function createActionDispatcher(hooks: DispatcherHooks, options: DispatcherOptions) {
|
||||
const { projectRoot, config, broadcast } = options;
|
||||
|
||||
return {
|
||||
/**
|
||||
* Dispatch a call-style action. Returns `{ result, reload }`.
|
||||
*/
|
||||
async handleCall(action: string, payload: any): Promise<DispatchResult> {
|
||||
const handler = hooks.actions[action];
|
||||
if (!handler) throw new Error(`Unknown action: ${action}`);
|
||||
|
||||
const sourceTools = createSourceTools({ projectRoot });
|
||||
let modified = false;
|
||||
|
||||
const ctx: ActionContext = {
|
||||
projectRoot,
|
||||
config,
|
||||
broadcast,
|
||||
source: sourceTools,
|
||||
async readFile(relativePath: string): Promise<string> {
|
||||
const resolved = safePath(projectRoot, relativePath);
|
||||
return fs.promises.readFile(resolved, 'utf8');
|
||||
},
|
||||
async writeFile(relativePath: string, content: string): Promise<void> {
|
||||
const resolved = safePath(projectRoot, relativePath);
|
||||
await fs.promises.writeFile(resolved, content);
|
||||
modified = true;
|
||||
},
|
||||
async readFileLines(relativePath: string): Promise<string[]> {
|
||||
const content = await ctx.readFile(relativePath);
|
||||
return content.split('\n');
|
||||
},
|
||||
runWorkerTask(modulePath: string, functionName: string, args: any[]) {
|
||||
if (!config._workerPool) throw new Error('WorkerPool is not initialized');
|
||||
return config._workerPool.runTask({ type: 'plugin-task', modulePath, functionName, args });
|
||||
}
|
||||
};
|
||||
|
||||
const result = await handler(payload, ctx);
|
||||
return { result, reload: modified || sourceTools._modified };
|
||||
},
|
||||
|
||||
/**
|
||||
* Dispatch a fire-and-forget event. Unknown events are silently ignored.
|
||||
*/
|
||||
handleEvent(name: string, data: any): void {
|
||||
const handler = hooks.events[name];
|
||||
if (!handler) return;
|
||||
|
||||
const sourceTools = createSourceTools({ projectRoot });
|
||||
const ctx: ActionContext = {
|
||||
projectRoot,
|
||||
config,
|
||||
broadcast,
|
||||
source: sourceTools,
|
||||
async readFile(relativePath: string): Promise<string> {
|
||||
const resolved = safePath(projectRoot, relativePath);
|
||||
return fs.promises.readFile(resolved, 'utf8');
|
||||
},
|
||||
async writeFile(relativePath: string, content: string): Promise<void> {
|
||||
const resolved = safePath(projectRoot, relativePath);
|
||||
await fs.promises.writeFile(resolved, content);
|
||||
},
|
||||
async readFileLines(relativePath: string): Promise<string[]> {
|
||||
const content = await ctx.readFile(relativePath);
|
||||
return content.split('\n');
|
||||
},
|
||||
runWorkerTask(modulePath: string, functionName: string, args: any[]) {
|
||||
if (!config._workerPool) throw new Error('WorkerPool is not initialized');
|
||||
return config._workerPool.runTask({ type: 'plugin-task', modulePath, functionName, args });
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
handler(data, ctx);
|
||||
} catch (e: any) {
|
||||
TUI.warn(`Event handler error [${name}]: ${e.message}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* @package @docmd/api
|
||||
* @website https://docmd.io
|
||||
* @repository https://github.com/docmd-io/docmd
|
||||
* @license MIT
|
||||
* @copyright Copyright (c) 2025-present docmd.io
|
||||
*
|
||||
* [docmd-source] - Please do not remove this header.
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Runtime dependency bootstrap for plugins, templates, and engines.
|
||||
*
|
||||
* This module is the single source of truth for the "fetch a missing
|
||||
* official dependency on first build" path. Both `hooks.ts` (plugin /
|
||||
* template loader) and `engine.ts` (engine loader) call into it.
|
||||
*
|
||||
* Why it exists:
|
||||
* - Security: replaces the previous `execSync(\`pnpm add ${pkg}\`)`
|
||||
* shell-string command, which was a CWE-78 surface if a name ever
|
||||
* leaked in from an untrusted config (fixed by strict regex +
|
||||
* `spawn` arg-array + defence-in-depth registry lookup).
|
||||
* - Reuse: the install pipeline was duplicated in hooks and engine;
|
||||
* one module, one set of behaviour changes.
|
||||
* - Idempotency: TUI status lines are reported through a per-build
|
||||
* cache so a dev-server rebuild that re-runs the loader doesn't
|
||||
* spam the same "WAIT / DONE" line pair for packages already on
|
||||
* disk.
|
||||
*
|
||||
* Public surface (re-exported from `index.ts`):
|
||||
* - `loadRuntimeRegistry()` — read & cache the generated registry
|
||||
* - `detectPackageManager(cwd)` — pick pnpm / yarn / bun / npm
|
||||
* - `getDocmdVersion()` — `@docmd/core` version (for pinning)
|
||||
* - `isValidRuntimeDepName(name)` — strict regex, returns boolean
|
||||
* - `installRuntimeDep(pkg)` — non-shell `spawn` install, true on ok
|
||||
* - `reportInstallStatus(shortName, status)` — idempotent TUI reporter
|
||||
* - `getBuildStatusReporter()` — single per-build reporter cache
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import nativeFs from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import process from 'node:process';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { TUI } from '@docmd/tui';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
// Monorepo root - two levels up from packages/api/dist/
|
||||
const __monorepoRoot = path.resolve(__dirname, '..', '..', '..');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Strict package-name regex (CWE-78 defence)
|
||||
// Accepts: @docmd/plugin-foo
|
||||
// @docmd/template-summer
|
||||
// @docmd/engine-rust
|
||||
// @docmd/plugin-math-katex (two-segment short names are fine)
|
||||
// Rejects: anything with shell metacharacters, scoped third-party names,
|
||||
// uppercase letters, or names that don't fit the @docmd/<kind>-*
|
||||
// pattern. A second defence lives in `installRuntimeDep`, which
|
||||
// cross-checks the lookup against `loadRuntimeRegistry()` so a
|
||||
// forbidden name that happens to match the regex still cannot be
|
||||
// installed.
|
||||
// ---------------------------------------------------------------------------
|
||||
const PACKAGE_NAME_RE = /^@docmd\/(?:plugin|template|engine)-[a-z0-9][a-z0-9.-]*$/;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registry loader
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _registry: Record<string, any> | null = null;
|
||||
|
||||
/**
|
||||
* Read the generated runtime registry for plugins / templates / engines.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. `<package-root>/registry/plugins.generated.json` (monorepo dev +
|
||||
* published package, both expose this path under `files`).
|
||||
* 2. `<monorepo-root>/packages/api/registry/plugins.generated.json`
|
||||
* (fallback for callers that import us from a nested dist path
|
||||
* that the first candidate doesn't satisfy).
|
||||
*
|
||||
* The result is cached per process so a hot dev-server loop doesn't
|
||||
* re-read the file on every hook call.
|
||||
*/
|
||||
export function loadRuntimeRegistry(): Record<string, any> {
|
||||
if (_registry) return _registry;
|
||||
const candidates = [
|
||||
path.resolve(__dirname, '..', 'registry', 'plugins.generated.json'),
|
||||
path.resolve(__monorepoRoot, 'packages', 'api', 'registry', 'plugins.generated.json'),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (nativeFs.existsSync(candidate)) {
|
||||
_registry = JSON.parse(nativeFs.readFileSync(candidate, 'utf8'));
|
||||
return _registry!;
|
||||
}
|
||||
}
|
||||
_registry = {};
|
||||
return _registry;
|
||||
}
|
||||
|
||||
/** Force-reload the registry cache (tests only). */
|
||||
export function _resetRuntimeRegistryCache(): void {
|
||||
_registry = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project inspection helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Detect the package manager used in `cwd` (or any of its ancestors).
|
||||
* Walks upward until a known lockfile is found; defaults to `npm`.
|
||||
*/
|
||||
export function detectPackageManager(cwd: string): 'pnpm' | 'yarn' | 'bun' | 'npm' {
|
||||
let dir = cwd;
|
||||
while (dir !== path.parse(dir).root) {
|
||||
if (nativeFs.existsSync(path.join(dir, 'pnpm-lock.yaml'))) return 'pnpm';
|
||||
if (nativeFs.existsSync(path.join(dir, 'yarn.lock'))) return 'yarn';
|
||||
if (nativeFs.existsSync(path.join(dir, 'bun.lockb'))) return 'bun';
|
||||
if (nativeFs.existsSync(path.join(dir, 'package-lock.json'))) return 'npm';
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
return 'npm';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the current `@docmd/core` version, used to pin `pkg@<version>`
|
||||
* installs to the same release line as the user's docmd. Falls back to
|
||||
* `latest` when the core package isn't resolvable (e.g. running inside
|
||||
* a CI image without node_modules linked).
|
||||
*/
|
||||
export function getDocmdVersion(): string {
|
||||
try {
|
||||
const corePkgPath = require.resolve('@docmd/core/package.json', {
|
||||
paths: [process.cwd(), __dirname, __monorepoRoot],
|
||||
});
|
||||
const pkg = JSON.parse(nativeFs.readFileSync(corePkgPath, 'utf8'));
|
||||
return pkg.version || 'latest';
|
||||
} catch {
|
||||
return 'latest';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Package-name validator (CWE-78 defence)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* True only when `name` is an `@docmd/<kind>-<short>` reference. This
|
||||
* is the FIRST line of defence; `installRuntimeDep` also cross-checks
|
||||
* against `loadRuntimeRegistry()` so a name that matches the regex but
|
||||
* isn't in the official catalog still cannot be installed.
|
||||
*/
|
||||
export function isValidRuntimeDepName(name: string): boolean {
|
||||
return typeof name === 'string' && PACKAGE_NAME_RE.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an npm package name to its registry short key. Returns null when
|
||||
* the name is not an `@docmd/<kind>-*` reference.
|
||||
*/
|
||||
function shortNameOf(packageName: string): string | null {
|
||||
if (!isValidRuntimeDepName(packageName)) return null;
|
||||
if (packageName.startsWith('@docmd/plugin-')) return packageName.replace('@docmd/plugin-', '');
|
||||
if (packageName.startsWith('@docmd/template-')) return packageName.replace('@docmd/template-', '');
|
||||
if (packageName.startsWith('@docmd/engine-')) return packageName.replace('@docmd/engine-', '');
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Idempotent TUI status reporter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Per-build cache of short-name → status pairs. Constructed once per
|
||||
* loader run via `getBuildStatusReporter()`. Subsequent attempts to
|
||||
* report the same short name in the same build are silently dropped,
|
||||
* so a dev-server rebuild can't spam the same line pair.
|
||||
*/
|
||||
interface StatusLine {
|
||||
status: 'WAIT' | 'DONE' | 'FAIL' | 'SKIP' | string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
type BuildReporter = {
|
||||
begin(shortName: string): void;
|
||||
finish(shortName: string, status: StatusLine['status']): void;
|
||||
setMessage(shortName: string, message: string): void;
|
||||
reset(): void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a fresh reporter. Each `loadPlugins` / `loadEngine` call gets
|
||||
* its own reporter so the cache is bound to one loader run.
|
||||
*/
|
||||
export function getBuildStatusReporter(): BuildReporter {
|
||||
const seen = new Map<string, StatusLine>();
|
||||
return {
|
||||
begin(shortName) {
|
||||
// Skip if we've already emitted a "WAIT" for this name this build —
|
||||
// the install is in flight (or finished); re-emitting would make the
|
||||
// TUI flicker on dev-server rebuilds.
|
||||
if (seen.has(shortName)) return;
|
||||
seen.set(shortName, { status: 'WAIT' });
|
||||
TUI.step(`Downloading missing runtime dep: ${shortName}`, 'WAIT');
|
||||
},
|
||||
finish(shortName, status) {
|
||||
const prev = seen.get(shortName);
|
||||
seen.set(shortName, { status, message: prev?.message });
|
||||
TUI.step(`Runtime dep ${status.toLowerCase()}: ${shortName}`, status);
|
||||
},
|
||||
setMessage(shortName, message) {
|
||||
const prev = seen.get(shortName) ?? { status: 'WAIT' };
|
||||
seen.set(shortName, { status: prev.status, message });
|
||||
},
|
||||
reset() {
|
||||
seen.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// `spawn`-based installer (CWE-78 fix)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the arg array for the user's package manager. Returned as an
|
||||
* array, never a string, so `spawn` doesn't go through a shell and the
|
||||
* package name can never be reinterpreted as a flag or command.
|
||||
*/
|
||||
function buildInstallArgs(packageName: string, pm: 'pnpm' | 'yarn' | 'bun' | 'npm'): string[] {
|
||||
switch (pm) {
|
||||
case 'pnpm': return ['add', packageName];
|
||||
case 'yarn': return ['add', packageName];
|
||||
case 'bun': return ['add', packageName];
|
||||
case 'npm': return ['install', packageName];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-shell install of an official runtime dependency. Replaces the
|
||||
* previous `execSync(\`${pm} add ${pkg}\`)` with `spawn` + arg array.
|
||||
*
|
||||
* Defence in depth (in order):
|
||||
* 1. `isValidRuntimeDepName(pkg)` — strict regex.
|
||||
* 2. `loadRuntimeRegistry()[shortName]` — official catalog lookup.
|
||||
* 3. `spawn(pm, [...args], { shell: false })` — never hits a shell.
|
||||
*
|
||||
* Returns true on a clean exit code from the package manager, false
|
||||
* otherwise. Caller decides whether a fail should be fatal.
|
||||
*/
|
||||
export function installRuntimeDep(packageName: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
if (!isValidRuntimeDepName(packageName)) {
|
||||
TUI.warn(`Refusing to install non-runtime dep: ${packageName}`);
|
||||
return resolve(false);
|
||||
}
|
||||
const shortName = shortNameOf(packageName);
|
||||
if (!shortName) return resolve(false);
|
||||
|
||||
const registry = loadRuntimeRegistry();
|
||||
if (!registry[shortName]) {
|
||||
TUI.warn(`Runtime dep "${shortName}" not found in official registry`);
|
||||
return resolve(false);
|
||||
}
|
||||
|
||||
const cwd = process.cwd();
|
||||
const pm = detectPackageManager(cwd);
|
||||
const version = getDocmdVersion();
|
||||
const versionedPackage =
|
||||
version === 'latest' ? packageName : `${packageName}@${version}`;
|
||||
|
||||
const reporter = getBuildStatusReporter();
|
||||
reporter.begin(shortName);
|
||||
|
||||
const args = buildInstallArgs(versionedPackage, pm);
|
||||
let stderr = '';
|
||||
let stdout = '';
|
||||
// shell: true only on Windows because npm/yarn/pnpm are .cmd batch
|
||||
// files there. On macOS/Linux, shell: false is more secure and
|
||||
// works because the package managers are real executables. The
|
||||
// package name is already validated by the strict regex above, so
|
||||
// enabling the shell on Windows introduces no injection surface.
|
||||
const useShell = process.platform === 'win32';
|
||||
const child = spawn(pm, args, { cwd, shell: useShell, timeout: 60_000 });
|
||||
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
||||
|
||||
child.on('error', (err) => {
|
||||
reporter.finish(shortName, 'FAIL');
|
||||
const surface = (stderr || err.message || 'unknown error')
|
||||
.toString()
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.slice(0, 3)
|
||||
.join(' | ');
|
||||
const isTemplate = packageName.startsWith('@docmd/template-');
|
||||
const hint = isTemplate
|
||||
? `Add "${packageName}" to your package.json dependencies, then run your normal install step.`
|
||||
: `Run "docmd add ${shortName}" to install it, or add "${packageName}" to your package.json.`;
|
||||
TUI.warn(
|
||||
`Auto-install of ${packageName} failed: ${surface}\n > ${hint}`,
|
||||
);
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
reporter.finish(shortName, 'DONE');
|
||||
return resolve(true);
|
||||
}
|
||||
reporter.finish(shortName, 'FAIL');
|
||||
const surface = stderr
|
||||
.toString()
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.slice(0, 3)
|
||||
.join(' | ');
|
||||
const isTemplate = packageName.startsWith('@docmd/template-');
|
||||
const hint = isTemplate
|
||||
? `Add "${packageName}" to your package.json dependencies, then run your normal install step.`
|
||||
: `Run "docmd add ${shortName}" to install it, or add "${packageName}" to your package.json.`;
|
||||
TUI.warn(
|
||||
`Auto-install of ${packageName} failed (exit ${code}): ${surface || 'unknown error'}\n > ${hint}`,
|
||||
);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers consumed by hooks.ts and engine.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Short key for a package name, used for status lines and registry
|
||||
* lookups. Returns null for names that fail validation.
|
||||
*/
|
||||
export function shortKey(packageName: string): string | null {
|
||||
return shortNameOf(packageName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-load `pkg` after an install attempt. Returns the module reference
|
||||
* or null when the import still fails. We use dynamic `import(pkgName)`
|
||||
* (not file:// resolution) so `exports` with only the `import`
|
||||
* condition still resolves on modern Node.
|
||||
*/
|
||||
export async function tryLoadAfterInstall(packageName: string): Promise<any | null> {
|
||||
try {
|
||||
return await import(packageName);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* @package @docmd/api
|
||||
* @website https://docmd.io
|
||||
* @repository https://github.com/docmd-io/docmd
|
||||
* @license MIT
|
||||
* @copyright Copyright (c) 2025-present docmd.io
|
||||
*
|
||||
* [docmd-source] - Please do not remove this header.
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Source editing tools for live-edit plugins.
|
||||
*
|
||||
* Plugins interact with rendered output (block IDs, plain-text offsets)
|
||||
* and these tools translate those references back to raw markdown source
|
||||
* positions so edits can be applied safely.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { createMarkdownProcessor } from '@docmd/parser';
|
||||
import { safePath } from '@docmd/utils';
|
||||
import type { BlockInfo, InlineSegment, SourceTools, TextLocation } from './types.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// safePath is re-exported from @docmd/utils. The previous duplicate
|
||||
// (D-M4) had a different error message but identical semantics; using
|
||||
// the canonical helper means the API surface and the runtime share one
|
||||
// implementation and one set of tests.
|
||||
|
||||
/**
|
||||
* Compute the number of lines occupied by YAML frontmatter (including the
|
||||
* trailing blank line that gray-matter strips). Mirrors the logic used in
|
||||
* `processContent` from `@docmd/parser`.
|
||||
*/
|
||||
function computeFrontmatterOffset(raw: string): number {
|
||||
if (!raw.startsWith('---')) return 0;
|
||||
const closingIndex = raw.indexOf('---', 3);
|
||||
if (closingIndex === -1) return 0;
|
||||
let count = raw.substring(0, closingIndex + 3).split('\n').length;
|
||||
if (raw[closingIndex + 3] === '\n') count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline segment builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Syntax marker lookup for open/close token pairs.
|
||||
* Returns `[before, after]` strings that wrap the text content in raw markdown.
|
||||
*/
|
||||
function syntaxForToken(token: any): [string, string] | null {
|
||||
switch (token.type) {
|
||||
case 'strong_open':
|
||||
return ['**', '**'];
|
||||
case 'em_open':
|
||||
return [token.markup || '*', token.markup || '*'];
|
||||
case 's_open':
|
||||
return ['~~', '~~'];
|
||||
case 'link_open': {
|
||||
const href = (token.attrs || []).find((a: string[]) => a[0] === 'href');
|
||||
return ['[', `](${href ? href[1] : ''})`];
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an array of inline segments from a raw markdown string.
|
||||
*
|
||||
* Each segment describes a contiguous run of text content and the surrounding
|
||||
* syntax markers (if any). `rawOffset` is the character index in `rawContent`
|
||||
* where the segment's *text* starts (after the opening syntax marker).
|
||||
*/
|
||||
function buildSegments(rawContent: string, md: any): InlineSegment[] {
|
||||
const tokens = md.parseInline(rawContent, {});
|
||||
if (!tokens.length || !tokens[0].children) return [];
|
||||
|
||||
const children = tokens[0].children;
|
||||
const segments: InlineSegment[] = [];
|
||||
const syntaxStack: ([string, string] | null)[] = [];
|
||||
|
||||
// Walk the raw string in parallel with the token stream to compute
|
||||
// rawOffset values. `rawCursor` tracks our position in `rawContent`.
|
||||
let rawCursor = 0;
|
||||
|
||||
for (const child of children) {
|
||||
if (child.nesting === 1) {
|
||||
// Opening tag - advance rawCursor past the opening marker
|
||||
const syn = syntaxForToken(child);
|
||||
if (syn) {
|
||||
const markerPos = rawContent.indexOf(syn[0], rawCursor);
|
||||
if (markerPos !== -1) {
|
||||
rawCursor = markerPos + syn[0].length;
|
||||
}
|
||||
}
|
||||
syntaxStack.push(syn);
|
||||
} else if (child.nesting === -1) {
|
||||
// Closing tag - advance rawCursor past the closing marker
|
||||
const syn = syntaxStack.pop();
|
||||
if (syn) {
|
||||
const closeMarker = syn[1];
|
||||
const markerPos = rawContent.indexOf(closeMarker, rawCursor);
|
||||
if (markerPos !== -1) {
|
||||
rawCursor = markerPos + closeMarker.length;
|
||||
}
|
||||
}
|
||||
} else if (child.type === 'code_inline') {
|
||||
// Self-contained segment with backtick syntax
|
||||
const backtick = child.markup || '`';
|
||||
const markerPos = rawContent.indexOf(backtick + child.content + backtick, rawCursor);
|
||||
if (markerPos !== -1) {
|
||||
segments.push({
|
||||
text: child.content,
|
||||
rawOffset: markerPos + backtick.length,
|
||||
rawLength: child.content.length,
|
||||
syntax: [backtick, backtick],
|
||||
});
|
||||
rawCursor = markerPos + backtick.length + child.content.length + backtick.length;
|
||||
}
|
||||
} else if (child.type === 'softbreak') {
|
||||
// Treat as newline in text content - skip in raw
|
||||
const nlPos = rawContent.indexOf('\n', rawCursor);
|
||||
if (nlPos !== -1) rawCursor = nlPos + 1;
|
||||
} else if (child.type === 'text') {
|
||||
// Plain text or text inside a syntax wrapper
|
||||
const textPos = rawContent.indexOf(child.content, rawCursor);
|
||||
const currentSyntax = syntaxStack.length > 0 ? syntaxStack[syntaxStack.length - 1] : null;
|
||||
if (textPos !== -1) {
|
||||
segments.push({
|
||||
text: child.content,
|
||||
rawOffset: textPos,
|
||||
rawLength: child.content.length,
|
||||
syntax: currentSyntax,
|
||||
});
|
||||
rawCursor = textPos + child.content.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build plain text content from segments (concatenated segment texts).
|
||||
*/
|
||||
function plainTextFromSegments(segments: InlineSegment[]): string {
|
||||
return segments.map((s) => s.text).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a `textOffset` (character position in plain text) to the segment
|
||||
* that contains it.
|
||||
*/
|
||||
function resolveCursor(
|
||||
segments: InlineSegment[],
|
||||
textOffset: number,
|
||||
): (InlineSegment & { segment: number }) | null {
|
||||
let accumulated = 0;
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const seg = segments[i];
|
||||
if (accumulated + seg.text.length > textOffset) {
|
||||
return { segment: i, ...seg };
|
||||
}
|
||||
accumulated += seg.text.length;
|
||||
}
|
||||
// If offset is exactly at the end, return the last segment
|
||||
if (segments.length > 0) {
|
||||
const last = segments[segments.length - 1];
|
||||
return { segment: segments.length - 1, ...last };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a source-tools instance bound to the given project root.
|
||||
*
|
||||
* All file paths passed to the returned methods are resolved relative to
|
||||
* `projectRoot` and validated against path traversal.
|
||||
*/
|
||||
export function createSourceTools({ projectRoot }: { projectRoot: string }): SourceTools & { _modified: boolean } {
|
||||
// Normalise projectRoot to an absolute path without trailing separator
|
||||
projectRoot = path.resolve(projectRoot);
|
||||
|
||||
// Create a markdown-it instance once (no dev features needed for parsing)
|
||||
const md = createMarkdownProcessor({ isDev: false }, () => {});
|
||||
|
||||
const tools: SourceTools & { _modified: boolean } = {
|
||||
_modified: false,
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// getBlockAt
|
||||
// -------------------------------------------------------------------
|
||||
async getBlockAt(
|
||||
file: string,
|
||||
blockRef: [number, number],
|
||||
options?: { textOffset?: number },
|
||||
): Promise<BlockInfo> {
|
||||
const filePath = safePath(projectRoot, file);
|
||||
const raw = await fs.promises.readFile(filePath, 'utf8');
|
||||
const fmOffset = computeFrontmatterOffset(raw);
|
||||
|
||||
const [startLine, endLine] = blockRef;
|
||||
const absStart = fmOffset + startLine;
|
||||
const absEnd = fmOffset + endLine;
|
||||
|
||||
const allLines = raw.split('\n');
|
||||
const blockLines = allLines.slice(absStart, absEnd);
|
||||
const rawBlock = blockLines.join('\n');
|
||||
|
||||
const segments = buildSegments(rawBlock, md);
|
||||
const textContent = plainTextFromSegments(segments);
|
||||
|
||||
let cursor: (InlineSegment & { segment: number }) | null = null;
|
||||
if (options && options.textOffset != null) {
|
||||
cursor = resolveCursor(segments, options.textOffset);
|
||||
}
|
||||
|
||||
return {
|
||||
id: null,
|
||||
line: { start: absStart, end: absEnd },
|
||||
raw: rawBlock,
|
||||
textContent,
|
||||
segments,
|
||||
cursor,
|
||||
ancestors: [],
|
||||
};
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// findText
|
||||
// -------------------------------------------------------------------
|
||||
async findText(
|
||||
file: string,
|
||||
blockRef: [number, number],
|
||||
text: string,
|
||||
textOffset?: number,
|
||||
): Promise<TextLocation | null> {
|
||||
const block = await tools.getBlockAt(file, blockRef, { textOffset });
|
||||
|
||||
// Find the segment containing the target text
|
||||
let accumulated = 0;
|
||||
for (const seg of block.segments) {
|
||||
const idx = seg.text.indexOf(text);
|
||||
if (idx !== -1) {
|
||||
const matchPos = accumulated + idx;
|
||||
// If textOffset is specified, ensure the match overlaps with the target position
|
||||
if (textOffset != null && (matchPos + text.length <= textOffset || matchPos > textOffset + text.length)) {
|
||||
accumulated += seg.text.length;
|
||||
continue;
|
||||
}
|
||||
const rawStartInBlock = seg.rawOffset + idx;
|
||||
const rawEndInBlock = rawStartInBlock + text.length;
|
||||
|
||||
const filePath = safePath(projectRoot, file);
|
||||
const raw = await fs.promises.readFile(filePath, 'utf8');
|
||||
const fmOffset = computeFrontmatterOffset(raw);
|
||||
const absLine = fmOffset + blockRef[0];
|
||||
|
||||
return {
|
||||
line: absLine,
|
||||
startCol: rawStartInBlock,
|
||||
endCol: rawEndInBlock,
|
||||
rawText: text,
|
||||
wrappingSyntax: {
|
||||
before: seg.syntax ? seg.syntax[0] : null,
|
||||
after: seg.syntax ? seg.syntax[1] : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
accumulated += seg.text.length;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// wrapText
|
||||
// -------------------------------------------------------------------
|
||||
async wrapText(
|
||||
file: string,
|
||||
blockRef: [number, number],
|
||||
text: string,
|
||||
textOffset: number,
|
||||
before: string,
|
||||
after: string,
|
||||
): Promise<void> {
|
||||
const loc = await tools.findText(file, blockRef, text, textOffset);
|
||||
if (!loc) throw new Error(`Text "${text}" not found in block`);
|
||||
|
||||
const filePath = safePath(projectRoot, file);
|
||||
const raw = await fs.promises.readFile(filePath, 'utf8');
|
||||
const allLines = raw.split('\n');
|
||||
|
||||
const line = allLines[loc.line];
|
||||
const newLine =
|
||||
line.substring(0, loc.startCol) +
|
||||
before +
|
||||
line.substring(loc.startCol, loc.endCol) +
|
||||
after +
|
||||
line.substring(loc.endCol);
|
||||
|
||||
allLines[loc.line] = newLine;
|
||||
await fs.promises.writeFile(filePath, allLines.join('\n'));
|
||||
tools._modified = true;
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// insertAfter
|
||||
// -------------------------------------------------------------------
|
||||
async insertAfter(
|
||||
file: string,
|
||||
blockRef: [number, number],
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const filePath = safePath(projectRoot, file);
|
||||
const raw = await fs.promises.readFile(filePath, 'utf8');
|
||||
const fmOffset = computeFrontmatterOffset(raw);
|
||||
const absEnd = fmOffset + blockRef[1];
|
||||
|
||||
const allLines = raw.split('\n');
|
||||
|
||||
// Insert content after the block with blank line padding
|
||||
const insertLines = ['', ...content.split('\n')];
|
||||
allLines.splice(absEnd, 0, ...insertLines);
|
||||
|
||||
await fs.promises.writeFile(filePath, allLines.join('\n'));
|
||||
tools._modified = true;
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// replaceBlock
|
||||
// -------------------------------------------------------------------
|
||||
async replaceBlock(
|
||||
file: string,
|
||||
blockRef: [number, number],
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const filePath = safePath(projectRoot, file);
|
||||
const raw = await fs.promises.readFile(filePath, 'utf8');
|
||||
const fmOffset = computeFrontmatterOffset(raw);
|
||||
|
||||
const absStart = fmOffset + blockRef[0];
|
||||
const absEnd = fmOffset + blockRef[1];
|
||||
|
||||
const allLines = raw.split('\n');
|
||||
const replacementLines = content.split('\n');
|
||||
allLines.splice(absStart, absEnd - absStart, ...replacementLines);
|
||||
|
||||
await fs.promises.writeFile(filePath, allLines.join('\n'));
|
||||
tools._modified = true;
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// removeBlock
|
||||
// -------------------------------------------------------------------
|
||||
async removeBlock(
|
||||
file: string,
|
||||
blockRef: [number, number],
|
||||
): Promise<void> {
|
||||
const filePath = safePath(projectRoot, file);
|
||||
const raw = await fs.promises.readFile(filePath, 'utf8');
|
||||
const fmOffset = computeFrontmatterOffset(raw);
|
||||
|
||||
const absStart = fmOffset + blockRef[0];
|
||||
const absEnd = fmOffset + blockRef[1];
|
||||
|
||||
const allLines = raw.split('\n');
|
||||
|
||||
// Remove the block lines
|
||||
allLines.splice(absStart, absEnd - absStart);
|
||||
|
||||
// Clean up consecutive blank lines around the removal point
|
||||
const i = absStart;
|
||||
while (i < allLines.length - 1 && allLines[i].trim() === '' && allLines[i + 1].trim() === '') {
|
||||
allLines.splice(i, 1);
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(filePath, allLines.join('\n'));
|
||||
tools._modified = true;
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// getBlocks — enumerate all top-level blocks in a file (D-H4)
|
||||
// -------------------------------------------------------------------
|
||||
// Every other method takes a `blockRef: [start, end]` pair, which
|
||||
// forces live-edit plugins to already KNOW the line numbers they
|
||||
// want to edit. A "click on a block to edit it" UX needs an
|
||||
// enumeration entry-point. This is it.
|
||||
//
|
||||
// Strategy: walk the file line by line, splitting on blank lines
|
||||
// (a paragraph-level block). A future enhancement could use the
|
||||
// full container normaliser to detect `::: ` and fenced blocks,
|
||||
// but blank-line splitting matches what 95% of live-edit UIs need
|
||||
// and is robust to every existing docmd file.
|
||||
async getBlocks(file: string): Promise<BlockInfo[]> {
|
||||
const filePath = safePath(projectRoot, file);
|
||||
const raw = await fs.promises.readFile(filePath, 'utf8');
|
||||
const fmOffset = computeFrontmatterOffset(raw);
|
||||
const lines = raw.split('\n');
|
||||
|
||||
const blocks: BlockInfo[] = [];
|
||||
let blockStart: number | null = null;
|
||||
const flush = (endLineExclusive: number) => {
|
||||
if (blockStart === null) return;
|
||||
// Convert 0-based start / end back to the [start, end] shape
|
||||
// other methods use, where `end` is exclusive (next-line index).
|
||||
const absStart = fmOffset + blockStart;
|
||||
const absEnd = fmOffset + endLineExclusive;
|
||||
if (absEnd > absStart) {
|
||||
const rawBlock = lines.slice(blockStart, endLineExclusive).join('\n');
|
||||
blocks.push({
|
||||
id: null,
|
||||
line: { start: absStart, end: absEnd },
|
||||
raw: rawBlock,
|
||||
textContent: rawBlock,
|
||||
segments: [],
|
||||
cursor: null,
|
||||
ancestors: []
|
||||
});
|
||||
}
|
||||
blockStart = null;
|
||||
};
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].trim() === '') {
|
||||
flush(i);
|
||||
} else if (blockStart === null) {
|
||||
blockStart = i;
|
||||
}
|
||||
}
|
||||
flush(lines.length);
|
||||
return blocks;
|
||||
},
|
||||
};
|
||||
|
||||
return tools;
|
||||
}
|
||||
@@ -0,0 +1,738 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* @package @docmd/api
|
||||
* @website https://docmd.io
|
||||
* @repository https://github.com/docmd-io/docmd
|
||||
* @license MIT
|
||||
* @copyright Copyright (c) 2025-present docmd.io
|
||||
*
|
||||
* [docmd-source] - Please do not remove this header.
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin Descriptor & Capabilities (§1, §3 of advanced-plugin-plan)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Known hook categories that a plugin can declare. */
|
||||
export type Capability =
|
||||
| 'markdown'
|
||||
| 'head'
|
||||
| 'body'
|
||||
| 'assets'
|
||||
| 'post-build'
|
||||
| 'actions'
|
||||
| 'events'
|
||||
| 'translations'
|
||||
| 'init'
|
||||
| 'build'
|
||||
| 'dev'
|
||||
| 'template';
|
||||
|
||||
/**
|
||||
* Every plugin should export a `plugin` descriptor.
|
||||
* Required starting 0.8.0; currently a soft deprecation warning is
|
||||
* emitted when missing.
|
||||
*/
|
||||
export interface PluginDescriptor {
|
||||
/** Unique identifier for this plugin. */
|
||||
name: string;
|
||||
/** Semver version string. */
|
||||
version: string;
|
||||
/** Declared hook categories this plugin uses. */
|
||||
capabilities: Capability[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action / Event system (RPC)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Context provided to plugin action and event handlers.
|
||||
*
|
||||
* Contains file I/O helpers, source editing tools, and project metadata.
|
||||
* All file operations are sandboxed to the project root directory.
|
||||
*/
|
||||
export interface ActionContext {
|
||||
/** Absolute path to the project root directory. */
|
||||
projectRoot: string;
|
||||
/** Current docmd site configuration. */
|
||||
config: any;
|
||||
/** Read a file relative to the project root. */
|
||||
readFile(relativePath: string): Promise<string>;
|
||||
/** Write a file relative to the project root. Sets the modification flag. */
|
||||
writeFile(relativePath: string, content: string): Promise<void>;
|
||||
/** Read a file as an array of lines. */
|
||||
readFileLines(relativePath: string): Promise<string[]>;
|
||||
/** Broadcast an event to all connected browser clients. */
|
||||
broadcast(event: string, data: any): void;
|
||||
/** Source editing tools for block-level markdown manipulation. */
|
||||
source: SourceTools;
|
||||
/** Execute a generic function inside the multi-threaded worker pool. */
|
||||
runWorkerTask<T = any>(modulePath: string, functionName: string, args: any[]): Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for a named plugin action (WebSocket RPC).
|
||||
* Returns a result that is sent back to the browser client.
|
||||
*/
|
||||
export type ActionHandler = (payload: any, ctx: ActionContext) => Promise<any>;
|
||||
|
||||
/** Handler for a fire-and-forget plugin event. */
|
||||
export type EventHandler = (data: any, ctx: ActionContext) => void;
|
||||
|
||||
/** Result of dispatching an action call. */
|
||||
export interface DispatchResult {
|
||||
result: any;
|
||||
reload: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Source Editing Tools
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Source editing tools that translate rendered-output references
|
||||
* (block IDs, text offsets) back to raw markdown source positions.
|
||||
*/
|
||||
export interface SourceTools {
|
||||
/** Get block content and inline segments at a given source map reference. */
|
||||
getBlockAt(file: string, blockRef: [number, number], options?: { textOffset?: number }): Promise<BlockInfo>;
|
||||
/** Locate text within a block and return its source position. */
|
||||
findText(file: string, blockRef: [number, number], text: string, textOffset?: number): Promise<TextLocation | null>;
|
||||
/** Wrap text within a block with syntax markers (e.g., `==`, `**`). */
|
||||
wrapText(file: string, blockRef: [number, number], text: string, textOffset: number, before: string, after: string): Promise<void>;
|
||||
/** Insert markdown content after a block. */
|
||||
insertAfter(file: string, blockRef: [number, number], content: string): Promise<void>;
|
||||
/** Replace an entire block's source lines. */
|
||||
replaceBlock(file: string, blockRef: [number, number], content: string): Promise<void>;
|
||||
/** Remove a block's source lines. */
|
||||
removeBlock(file: string, blockRef: [number, number]): Promise<void>;
|
||||
/**
|
||||
* Enumerate every top-level block in a file (D-H4). Returns
|
||||
* `BlockInfo[]` with `line.start` and `line.end` populated — the
|
||||
* other methods can then be called with the returned blockRef.
|
||||
* Blocks are delimited by blank lines (a paragraph-level split
|
||||
* that handles the common case of editing a single paragraph or
|
||||
* list at a time).
|
||||
*/
|
||||
getBlocks(file: string): Promise<BlockInfo[]>;
|
||||
}
|
||||
|
||||
/** Information about a block in the markdown source. */
|
||||
export interface BlockInfo {
|
||||
id: string | null;
|
||||
line: { start: number; end: number };
|
||||
raw: string;
|
||||
textContent: string;
|
||||
segments: InlineSegment[];
|
||||
cursor: InlineSegment | null;
|
||||
ancestors: any[];
|
||||
}
|
||||
|
||||
/** A contiguous run of text content with optional surrounding syntax. */
|
||||
export interface InlineSegment {
|
||||
text: string;
|
||||
rawOffset: number;
|
||||
rawLength: number;
|
||||
syntax: [string, string] | null;
|
||||
}
|
||||
|
||||
/** Source position of located text within a block. */
|
||||
export interface TextLocation {
|
||||
line: number;
|
||||
startCol: number;
|
||||
endCol: number;
|
||||
rawText: string;
|
||||
wrappingSyntax: { before: string | null; after: string | null };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin Module Interface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Interface for a docmd plugin module.
|
||||
*
|
||||
* Plugins can export any combination of build-time hooks and runtime
|
||||
* action/event handlers.
|
||||
*/
|
||||
export interface PluginModule {
|
||||
/** Plugin descriptor (required starting 0.8.0). */
|
||||
plugin?: PluginDescriptor;
|
||||
/** Extend the markdown-it parser instance. */
|
||||
markdownSetup?(md: any, options?: any): void;
|
||||
/** Inject meta/link tags into the HTML head. */
|
||||
generateMetaTags?(config: any, page: any, relativePathToRoot: string): string | Promise<string>;
|
||||
/** Inject scripts into head and/or body. */
|
||||
generateScripts?(config: any, options?: any): { headScriptsHtml?: string; bodyScriptsHtml?: string };
|
||||
/** Define external assets (JS/CSS) to inject. */
|
||||
getAssets?(options?: any): Asset[];
|
||||
/** Run logic before HTML generation, after markdown parsing. */
|
||||
onBeforeBuild?(ctx: BeforeBuildContext): Promise<void>;
|
||||
/** Run logic after all HTML files are generated. */
|
||||
onPostBuild?(ctx: PostBuildContext): Promise<void>;
|
||||
/** Locale-specific UI string overrides. */
|
||||
translations?(localeId: string, options?: any): Record<string, string>;
|
||||
/** Named action handlers for WebSocket RPC calls from the browser. */
|
||||
actions?: Record<string, ActionHandler>;
|
||||
/** Named event handlers for fire-and-forget messages from the browser. */
|
||||
events?: Record<string, EventHandler>;
|
||||
/** Whether this plugin should run on noStyle pages (default: true). */
|
||||
noStyle?: boolean;
|
||||
|
||||
// --- Lifecycle Hooks ---
|
||||
/** Read/modify normalized config right after initialization. */
|
||||
onConfigResolved?(config: any): void | Promise<void>;
|
||||
/** Access the dev server instance. */
|
||||
onDevServerReady?(server: any, wss: any): void | Promise<void>;
|
||||
/** Modify raw markdown before parsing. Called per page. */
|
||||
onBeforeParse?(src: string, frontmatter: any, filePath?: string): string | Promise<string>;
|
||||
onAfterParse?(html: string, frontmatter: any, filePath?: string): string | Promise<string>;
|
||||
/**
|
||||
* Called BEFORE template rendering. Receives the page context including
|
||||
* `sourcePath` (absolute path to the source .md file), `frontmatter`,
|
||||
* and `html`. Mutations are reflected in the rendered output.
|
||||
*
|
||||
* This is the right hook for plugins that need to inject data derived
|
||||
* from the source file (e.g. reading frontmatter, computing metadata)
|
||||
* before the template runs.
|
||||
*/
|
||||
onBeforeRender?(page: PageContext): void | Promise<void>;
|
||||
/** Access fully assembled page object before write. */
|
||||
onPageReady?(page: any): void | Promise<void>;
|
||||
|
||||
// --- Template System (new in 0.8.7) ---
|
||||
/**
|
||||
* Template file overrides. Requires the `template` capability on the
|
||||
* plugin descriptor. The resolver in @docmd/ui merges these with the
|
||||
* default templates shipped with the core, falling back to the default
|
||||
* for any slot the plugin does not provide.
|
||||
*/
|
||||
templates?: TemplateHook[];
|
||||
/**
|
||||
* CSS/JS asset bundles shipped with the template. Requires the `template`
|
||||
* capability. Loaded at priority 10 by default so user customCss (15) wins.
|
||||
*/
|
||||
templateAssets?: TemplateAssetHook[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page Context — available in onBeforeRender
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Page context object passed to `onBeforeRender`.
|
||||
* Always includes `sourcePath` so plugins can read the source file,
|
||||
* compute file-based metadata, and inject it before templating.
|
||||
*/
|
||||
export interface PageContext {
|
||||
/** Absolute path to the source .md file. Always set. */
|
||||
sourcePath: string;
|
||||
/** Parsed frontmatter object. Plugins may mutate this. */
|
||||
frontmatter: Record<string, any>;
|
||||
/** Rendered HTML body (between template slots). Plugins may mutate this. */
|
||||
html: string;
|
||||
/** Locale id active for this page. */
|
||||
localeId?: string;
|
||||
/** Version id active for this page (if versioning enabled). */
|
||||
versionId?: string;
|
||||
/** Relative path from the output file to the site root. */
|
||||
relativePathToRoot?: string;
|
||||
/** Execute a generic function inside the multi-threaded worker pool. */
|
||||
runWorkerTask<T = any>(modulePath: string, functionName: string, args: any[]): Promise<T>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build Contexts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* D-M2: minimal canonical types for the build contexts. Previously both
|
||||
* `config` and `pages` were `any`, which forced plugin authors to either
|
||||
* reach for type assertions or write untyped code. Plugin authors who
|
||||
* need richer shapes can still cast to a wider type — these are the
|
||||
* structural minimums that every plugin can rely on.
|
||||
*
|
||||
* The full config shape lives in `@docmd/core`'s `normalizeConfig`
|
||||
* output; exposing it here would create a circular import. Plugin
|
||||
* authors who need the full shape can extend with intersection types.
|
||||
*/
|
||||
export interface DocConfigShape {
|
||||
title: string;
|
||||
url?: string;
|
||||
base?: string;
|
||||
src?: string;
|
||||
out?: string;
|
||||
theme?: Record<string, any>;
|
||||
layout?: Record<string, any>;
|
||||
i18n?: Record<string, any>;
|
||||
versions?: Record<string, any>;
|
||||
workspace?: Record<string, any>;
|
||||
plugins?: Record<string, any>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface PageInfoShape {
|
||||
sourcePath: string;
|
||||
outputPath: string;
|
||||
frontmatter: Record<string, any>;
|
||||
htmlContent?: string;
|
||||
rawMarkdown?: string;
|
||||
headings?: Array<{ id: string; text: string; level: number }>;
|
||||
urls?: Record<string, string>;
|
||||
urlContext?: Record<string, any>;
|
||||
config?: DocConfigShape;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/** Context provided to onBeforeBuild hooks. */
|
||||
export interface BeforeBuildContext {
|
||||
config: DocConfigShape;
|
||||
pages: PageInfoShape[];
|
||||
tui: any; // @docmd/tui instance for progress bars and spinners
|
||||
options: any;
|
||||
/** Execute a generic function inside the multi-threaded worker pool. */
|
||||
runWorkerTask<T = any>(modulePath: string, functionName: string, args: any[]): Promise<T>;
|
||||
}
|
||||
|
||||
/** Context provided to onPostBuild hooks. */
|
||||
export interface PostBuildContext {
|
||||
config: DocConfigShape;
|
||||
pages: PageInfoShape[];
|
||||
outputDir: string;
|
||||
tui: any; // @docmd/tui instance for progress bars and spinners
|
||||
log: (msg: string) => void;
|
||||
options: any;
|
||||
/** Execute a generic function inside the multi-threaded worker pool. */
|
||||
runWorkerTask<T = any>(modulePath: string, functionName: string, args: any[]): Promise<T>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook Registry Shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The shape of the hooks object maintained by the plugin loader. */
|
||||
export interface PluginHooks {
|
||||
markdownSetup: ((md: any) => void)[];
|
||||
injectHead: ((config: any, pageContext: any, root?: string) => string | Promise<string>)[];
|
||||
injectBody: ((config: any, pageContext: any) => string | Promise<string>)[];
|
||||
onBeforeBuild: ((ctx: BeforeBuildContext) => Promise<void>)[];
|
||||
onPostBuild: ((ctx: PostBuildContext) => Promise<void>)[];
|
||||
assets: (() => Asset[] | Promise<Asset[]>)[];
|
||||
translations: ((localeId: string) => Record<string, string> | Promise<Record<string, string>>)[];
|
||||
actions: Record<string, ActionHandler>;
|
||||
events: Record<string, EventHandler>;
|
||||
|
||||
// Lifecycle Hooks
|
||||
onConfigResolved: ((config: any) => void | Promise<void>)[];
|
||||
onDevServerReady: ((server: any, wss: any) => void | Promise<void>)[];
|
||||
onBeforeParse: ((src: string, frontmatter: any, filePath?: string) => string | Promise<string>)[];
|
||||
onAfterParse: ((html: string, frontmatter: any, filePath?: string) => string | Promise<string>)[];
|
||||
/** Called before template rendering. Receives full PageContext. */
|
||||
onBeforeRender: ((page: PageContext) => void | Promise<void>)[];
|
||||
onPageReady: ((page: any) => void | Promise<void>)[];
|
||||
|
||||
// Template System (new in 0.8.7)
|
||||
/** Template file overrides registered by template plugins. */
|
||||
templates: TemplateHook[];
|
||||
/** Asset bundles registered by template plugins. */
|
||||
templateAssets: TemplateAssetHook[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Engine Interface (§ Engine Abstraction Layer)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Task definition for engine execution.
|
||||
* Engines receive tasks and return results - they don't know or care
|
||||
* what the task does, they just execute it.
|
||||
*/
|
||||
export interface EngineTask {
|
||||
/** Task type identifier (e.g., 'file:discover', 'git:log', 'search:index') */
|
||||
type: string;
|
||||
/** Task payload - any serializable data */
|
||||
payload: any;
|
||||
/** Optional timeout in milliseconds */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from an engine task execution.
|
||||
*/
|
||||
export interface EngineResult<T = any> {
|
||||
/** Whether the task succeeded */
|
||||
success: boolean;
|
||||
/** Result data if successful */
|
||||
data?: T;
|
||||
/** Error message if failed */
|
||||
error?: string;
|
||||
/** Execution time in milliseconds */
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Engine interface for pluggable build acceleration.
|
||||
*
|
||||
* Engines are simple task executors - they receive a task and return a result.
|
||||
* The API layer controls what tasks are allowed and how they're structured.
|
||||
* This keeps engines language-agnostic and allows any tool (docmd, docmd-search, etc.)
|
||||
* to use them without tight coupling.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const engine = await loadEngine('rust');
|
||||
* const result = await engine.run({ type: 'file:discover', payload: { dir: './docs' } });
|
||||
* if (result.success) {
|
||||
* console.log('Found files:', result.data);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface Engine {
|
||||
/** Engine descriptor with name and version. */
|
||||
readonly name: string;
|
||||
readonly version: string;
|
||||
|
||||
/**
|
||||
* Execute a task and return the result.
|
||||
* This is the only method engines need to implement.
|
||||
*
|
||||
* @param task - The task to execute
|
||||
* @returns The result of the task execution
|
||||
*/
|
||||
run<T = any>(task: EngineTask): Promise<EngineResult<T>>;
|
||||
|
||||
/**
|
||||
* Check if the engine supports a given task type.
|
||||
* Optional - if not implemented, engine assumes it can try any task.
|
||||
*/
|
||||
supports?(taskType: string): boolean;
|
||||
|
||||
/**
|
||||
* Initialize the engine. Called once before first use.
|
||||
*/
|
||||
init?(options?: EngineInitOptions): Promise<void>;
|
||||
|
||||
/**
|
||||
* Clean up resources. Called when the engine is no longer needed.
|
||||
*/
|
||||
destroy?(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options passed to engine initialization.
|
||||
*/
|
||||
export interface EngineInitOptions {
|
||||
/** Project root directory. */
|
||||
projectRoot?: string;
|
||||
/** Enable debug logging. */
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Engine loader function type.
|
||||
*/
|
||||
export type EngineLoader = () => Promise<Engine | null>;
|
||||
|
||||
/**
|
||||
* Registry of available engine loaders.
|
||||
*/
|
||||
export const engineRegistry: Map<string, EngineLoader> = new Map();
|
||||
|
||||
/**
|
||||
* Register an engine loader.
|
||||
*/
|
||||
export function registerEngine(name: string, loader: EngineLoader): void {
|
||||
engineRegistry.set(name, loader);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task Types (defined by API, not engines)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Built-in task types that the API layer supports.
|
||||
* Engines don't define these - the API does.
|
||||
*/
|
||||
export type BuiltinTaskType =
|
||||
// File operations
|
||||
| 'file:discover' // Discover files in directory tree
|
||||
| 'file:read' // Read single file
|
||||
| 'file:readBatch' // Read multiple files
|
||||
| 'file:write' // Write file
|
||||
| 'file:exists' // Check if file exists
|
||||
// Git operations
|
||||
| 'git:log' // Get git log for file(s)
|
||||
| 'git:status' // Get git status
|
||||
// Search operations
|
||||
| 'search:index' // Build search index
|
||||
| 'search:query' // Query search index
|
||||
// Generic
|
||||
| 'exec:script'; // Execute arbitrary script
|
||||
|
||||
/**
|
||||
* Payload for file:discover task.
|
||||
*/
|
||||
export interface FileDiscoverPayload {
|
||||
dir: string;
|
||||
extensions?: string[];
|
||||
exclude?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for file:readBatch task.
|
||||
*/
|
||||
export interface FileReadBatchPayload {
|
||||
paths: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for git:log task.
|
||||
*/
|
||||
export interface GitLogPayload {
|
||||
filePaths: string[];
|
||||
maxCommits?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for search:index task.
|
||||
*/
|
||||
export interface SearchIndexPayload {
|
||||
documents: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
path: string;
|
||||
locale?: string;
|
||||
version?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Asset Declaration (typed, used by getAssets + template assets)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Asset kind. `static` copies the file verbatim; `css`/`js` emit <link>/<script>. */
|
||||
export type AssetKind = 'css' | 'js' | 'static';
|
||||
|
||||
/** Where an asset is injected in the page. */
|
||||
export type AssetPosition = 'head' | 'body' | 'footer';
|
||||
|
||||
/**
|
||||
* Asset descriptor returned by `getAssets()` or declared in a template.
|
||||
*
|
||||
* `priority` controls load order. Lower loads first, higher loads last.
|
||||
* - `0` base (e.g. docmd-main.css)
|
||||
* - `5` theme colour overlay (e.g. theme-sky.css)
|
||||
* - `10` template structure
|
||||
* - `15` user customCss / customJs (always wins)
|
||||
* - `20` other plugins
|
||||
*
|
||||
* Templates MUST NOT use `!important` in CSS so that customCss overrides
|
||||
* remain authoritative.
|
||||
*/
|
||||
export interface Asset {
|
||||
/** Asset kind. */
|
||||
type: AssetKind;
|
||||
/** Absolute or template-relative path to the source file. */
|
||||
path?: string;
|
||||
/** Public URL/path where the asset will be served from. */
|
||||
url?: string;
|
||||
/** Load order. Lower = earlier. Defaults to 0. */
|
||||
priority?: number;
|
||||
/** Where in the document to inject. Defaults to `head` for css, `body` for js. */
|
||||
position?: AssetPosition;
|
||||
/** Optional content-hash suffix (e.g. for cache busting). */
|
||||
hash?: string;
|
||||
/** Optional inline content (mutually exclusive with `path`). */
|
||||
inline?: string;
|
||||
|
||||
/**
|
||||
* Optional condition for conditional loading. When set, the asset's `<link>`
|
||||
* or `<script>` tag is only emitted on pages where the condition matches.
|
||||
*
|
||||
* Omit this field (or leave `condition` undefined) to keep the legacy
|
||||
* behaviour: include the asset on every page.
|
||||
*
|
||||
* Evaluated per page at build time, so the cost is paid once during the
|
||||
* build, not at runtime. Conditional assets still have their files copied
|
||||
* to the output directory as usual — only the HTML tag is skipped when the
|
||||
* condition fails.
|
||||
*
|
||||
* @example Only load mermaid on pages that actually have a diagram block
|
||||
* ```ts
|
||||
* {
|
||||
* src: 'init-mermaid.js',
|
||||
* dest: 'assets/js/init-mermaid.js',
|
||||
* type: 'js',
|
||||
* position: 'body',
|
||||
* attributes: { type: 'module' },
|
||||
* condition: { pageHtmlMatches: 'class="mermaid"' }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
condition?: AssetCondition;
|
||||
|
||||
// --- Legacy aliases (deprecated; kept for backwards compat with 0.8.x) ---
|
||||
/** @deprecated Use `path`. */
|
||||
src?: string;
|
||||
/** @deprecated Use `url`. */
|
||||
dest?: string;
|
||||
/** @deprecated Use `position`. */
|
||||
location?: 'head' | 'body' | 'none';
|
||||
/** @deprecated Use `position`. Legacy maps to `head`/`body`/`none`. */
|
||||
attributes?: Record<string, string | boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicate evaluated against the rendered page to decide whether a
|
||||
* conditional asset should be injected. All keys present in the condition
|
||||
* must match (logical AND). Within a key, multiple values are OR-ed.
|
||||
*/
|
||||
export interface AssetCondition {
|
||||
/**
|
||||
* The asset is injected only if the page's HTML (post-markdown, pre-template)
|
||||
* contains at least one of the given substrings. Use this to gate JS bundles
|
||||
* that init a specific markup — e.g. `class="mermaid"` for mermaid blocks,
|
||||
* `class="katex"` for KaTeX math, etc.
|
||||
*
|
||||
* Substring (not selector) so the check stays O(n) and dependency-free.
|
||||
* For more advanced matching, combine multiple substrings (OR-ed).
|
||||
*/
|
||||
pageHtmlMatches?: string | string[];
|
||||
/**
|
||||
* The asset is injected only if the page's parsed frontmatter has this key
|
||||
* defined (any value, including `false`). Useful when a page opts in via
|
||||
* frontmatter (e.g. `math: true`).
|
||||
*/
|
||||
frontmatterHas?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Template System (new in 0.8.7)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Logical template slots a template can override.
|
||||
*
|
||||
* These match the file names that ship in `@docmd/ui/templates/`. A template
|
||||
* that provides, say, only `menubar.ejs` will inherit the rest of the layout
|
||||
* from the default templates shipped with `@docmd/ui`.
|
||||
*
|
||||
* Conventions:
|
||||
* - Files live under `templates/` in the template package.
|
||||
* - Partials live under `templates/partials/`.
|
||||
* - File names match the slot name exactly (e.g. `layout.ejs` for `layout`).
|
||||
*
|
||||
* Templates MAY also define custom partials and include them from inside their
|
||||
* own EJS files; only the slots listed here participate in the default
|
||||
* resolution chain.
|
||||
*
|
||||
* Slots currently with default files in `@docmd/ui`:
|
||||
* layout, 404, toc, navigation, footer, menubar, options-menu,
|
||||
* project-switcher, version-dropdown, language-switcher, banner,
|
||||
* cookie-consent.
|
||||
*
|
||||
* `no-style` pages are not a template slot — they always use the default
|
||||
* `templates/no-style.ejs` and are unaffected by the active template.
|
||||
*/
|
||||
export type TemplateSlot =
|
||||
| 'layout'
|
||||
| '404'
|
||||
| 'toc'
|
||||
| 'navigation'
|
||||
| 'footer'
|
||||
| 'menubar'
|
||||
| 'options-menu'
|
||||
| 'project-switcher'
|
||||
| 'version-dropdown'
|
||||
| 'language-switcher'
|
||||
| 'banner'
|
||||
| 'cookie-consent';
|
||||
|
||||
/**
|
||||
* A single template file override registered by a template plugin.
|
||||
*
|
||||
* Templates register one entry per file they ship. The resolver merges
|
||||
* the entries with the default templates from `@docmd/ui`, falling back
|
||||
* to the default for any slot the template does not provide.
|
||||
*/
|
||||
export interface TemplateHook {
|
||||
/** Logical slot this file overrides. */
|
||||
type: TemplateSlot;
|
||||
/** Absolute path to the `.ejs` file inside the template package. */
|
||||
templatePath: string;
|
||||
/**
|
||||
* Priority within the same slot. Higher wins. Defaults to 0.
|
||||
* Useful if multiple plugins contribute templates for the same slot.
|
||||
*/
|
||||
priority?: number;
|
||||
/**
|
||||
* Glob patterns (e.g. `"blog/*"`) of page paths where this template
|
||||
* applies. Omit or pass `[]` to apply to all pages.
|
||||
*/
|
||||
pages?: string[];
|
||||
/**
|
||||
* Glob patterns of page paths this template must NOT apply to.
|
||||
* Evaluated before `pages`.
|
||||
*/
|
||||
exclude?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset descriptor for a template's own CSS/JS bundle.
|
||||
*
|
||||
* Templates ship a single CSS file (and optionally a single JS file). These
|
||||
* are loaded after the base `docmd-main.css` and before any user `customCss`
|
||||
* so the user can always override a template's styles.
|
||||
*/
|
||||
export interface TemplateAssetHook {
|
||||
type: 'css' | 'js';
|
||||
/** Absolute path to the file inside the template package. */
|
||||
path: string;
|
||||
/** Load priority. Defaults to 10 for templates. */
|
||||
priority?: number;
|
||||
/** Head or body injection. Defaults are `head` for css, `body` for js. */
|
||||
position?: AssetPosition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context passed to the template resolver. The resolver decides which
|
||||
* template file to use for a given slot on a given page.
|
||||
*/
|
||||
export interface TemplateResolutionContext {
|
||||
/** Slot being resolved. */
|
||||
type: TemplateSlot;
|
||||
/** Absolute path of the page being rendered (post-build path, e.g. `/guide/intro.html`). */
|
||||
pagePath: string;
|
||||
/** Page frontmatter (may contain `template` override). */
|
||||
frontmatter: Record<string, any>;
|
||||
/** Resolved site config. */
|
||||
config: any;
|
||||
/** Locale id for the page, if any. */
|
||||
localeId?: string;
|
||||
/** Version id for the page, if any. */
|
||||
versionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of resolving a template slot.
|
||||
*
|
||||
* `source === 'default'` means no template plugin claimed the slot and the
|
||||
* core `@docmd/ui` default is being used. `source === 'frontmatter'` /
|
||||
* `'config'` indicates the override path. `source === 'plugin'` indicates
|
||||
* a registered template plugin satisfied the request.
|
||||
*/
|
||||
export interface ResolvedTemplate {
|
||||
/** Absolute filesystem path to the EJS file to render. */
|
||||
templatePath: string;
|
||||
/** How the resolution arrived at this template. */
|
||||
source: 'default' | 'frontmatter' | 'config' | 'plugin';
|
||||
/** Plugin name, when `source === 'plugin'`. */
|
||||
pluginName?: string;
|
||||
/** Slot that was resolved. */
|
||||
type: TemplateSlot;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025-present docmd.io
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,277 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://docmd.io">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/docmd-io/docmd/blob/main/packages/ui/assets/images/docmd-logo-dark.png?raw=true" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://github.com/docmd-io/docmd/blob/main/packages/ui/assets/images/docmd-logo-dark.png?raw=true" />
|
||||
<img src="https://github.com/docmd-io/docmd/blob/main/packages/ui/assets/images/docmd-logo-dark.png?raw=true" alt="docmd" width="210" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
<br/>
|
||||
|
||||
<p><b>Production-ready documentation from Markdown, in seconds.</b><br/>Zero config. AI-native. Built for developers.</p>
|
||||
|
||||
<p>
|
||||
<a href="https://www.npmjs.com/package/@docmd/core"><img src="https://img.shields.io/npm/v/@docmd/core.svg?style=flat-square&color=CB3837" alt="npm version"></a>
|
||||
<a href="https://www.npmjs.com/package/@docmd/core?activeTab=versions"><img src="https://img.shields.io/npm/dm/@docmd/core.svg?style=flat-square&color=38bd24" alt="monthly downloads"></a>
|
||||
<a href="https://github.com/docmd-io/docmd"><img src="https://img.shields.io/github/stars/docmd-io/docmd?style=flat-square&logo=github" alt="GitHub stars"></a>
|
||||
<a href="https://github.com/docmd-io/docmd/blob/main/LICENSE"><img src="https://img.shields.io/github/license/docmd-io/docmd.svg?style=flat-square&color=A31F34" alt="license"></a>
|
||||
</p>
|
||||
|
||||
<h4>
|
||||
<a href="https://docmd.io">Website</a> ·
|
||||
<a href="https://docs.docmd.io">Documentation</a> ·
|
||||
<a href="https://live.docmd.io">Live Editor</a> ·
|
||||
<a href="https://github.com/docmd-io/docmd-skills">Agent Skills</a> ·
|
||||
<a href="https://github.com/docmd-io/docmd/issues">Report a Bug</a>
|
||||
</h4>
|
||||
|
||||
<br/>
|
||||
|
||||
<a href="https://docs.docmd.io">
|
||||
<img width="820" alt="docmd default theme — light and dark mode preview" src="https://raw.githubusercontent.com/docmd-io/docmd/refs/heads/main/assets/docmd-cover.webp" />
|
||||
</a>
|
||||
|
||||
<br/><br/>
|
||||
|
||||
</div>
|
||||
|
||||
## Quick Start
|
||||
|
||||
Run docmd in any folder with Markdown files — no install needed:
|
||||
|
||||
```bash
|
||||
npx @docmd/core dev
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>Opens at <code>http://localhost:3000</code></b></summary><br>
|
||||
|
||||
```bash
|
||||
_ _
|
||||
_| |___ ___ _____ _| |
|
||||
| . | . | _| | . |
|
||||
|___|___|___|_|_|_|___|
|
||||
|
||||
v1.x.x
|
||||
|
||||
┌─ Build
|
||||
│ Engine JS
|
||||
│ Source docs/
|
||||
│ Output site/
|
||||
│ Versions 2 (06, 05)
|
||||
│ Locales 7 (en, hi, zh, es, de, ja, fr)
|
||||
└──────────────────────────────────────────────────────────
|
||||
┌─ Data Indexing
|
||||
│ [ DONE ] Syncing git metadata
|
||||
│ [ DONE ] Building semantic search index (multi-version)
|
||||
└──────────────────────────────────────────────────────────
|
||||
┌─ Publishing
|
||||
│ [ DONE ] Generated robots.txt
|
||||
│ [ DONE ] Generated .nojekyll (disables Jekyll on GitHub Pages)
|
||||
│ [ DONE ] Generated sitemap
|
||||
│ [ DONE ] Generating LLMs context files
|
||||
└──────────────────────────────────────────────────────────
|
||||
|
||||
⬢ Initial build completed in 1.2s.
|
||||
|
||||
┌─ Watching
|
||||
│ Source ./docs
|
||||
│ Config ./docmd.config.json
|
||||
│ Assets ./assets
|
||||
└──────────────────────────────────────────────────────────
|
||||
┌─ Development Server Running
|
||||
│ Local Access http://127.0.0.1:3000
|
||||
│ Network Access http://192.168.1.6:3000
|
||||
│ Serving from ./site
|
||||
└──────────────────────────────────────────────────────────
|
||||
```
|
||||
</details>
|
||||
|
||||
<p align="center">
|
||||
<img alt="docmd dev server preview" width="820" src="https://docmd.io/assets/images/dev-preview.gif">
|
||||
</p>
|
||||
|
||||
Navigation is generated from your file structure. No config file, no frontmatter required, no framework to learn.
|
||||
|
||||
**When you're ready to ship:**
|
||||
|
||||
```bash
|
||||
npx @docmd/core build
|
||||
```
|
||||
|
||||
This outputs a highly optimized static site (SPA) ready for deployment to Vercel, Cloudflare Pages, Netlify, GitHub Pages, or any static host.
|
||||
|
||||
**Requirements:** Node.js 18+
|
||||
|
||||
<details>
|
||||
<summary><b>Or install globally / via Docker</b></summary><br/>
|
||||
|
||||
```bash
|
||||
# Install globally via npm
|
||||
npm install -g @docmd/core
|
||||
|
||||
# Or via pnpm
|
||||
pnpm add -g @docmd/core
|
||||
|
||||
# Run it
|
||||
docmd dev # start dev server
|
||||
docmd build # build for deployment
|
||||
```
|
||||
|
||||
Or run via Docker:
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 ghcr.io/docmd-io/docmd:0.8.7
|
||||
```
|
||||
|
||||
> Pin a version for reproducible builds.
|
||||
|
||||
</details>
|
||||
|
||||
## Why docmd?
|
||||
|
||||
| Feature | docmd | Docusaurus | MkDocs | VitePress | Mintlify |
|
||||
| :--- | :---: | :---: | :---: | :---: | :---: |
|
||||
| **Config required** | **None** | `docusaurus.config.js` | `mkdocs.yml` | `config.mts` | `docs.json` |
|
||||
| **JS payload** | **~18 kb** | ~250 kb | ~40 kb | ~50 kb | ~120 kb |
|
||||
| **Navigation** | **Instant SPA** | React SPA | Full reload | Vue SPA | Hosted SPA |
|
||||
| **Versioning** | **Native** | Native (complex) | mike plugin | Manual | Native |
|
||||
| **i18n** | **Native** | Native (complex) | Plugin-based | Native | Native |
|
||||
| **Multi-project** | **Native** | Plugin | Plugin | - | - |
|
||||
| **Search** | **Built-in** | Algolia (cloud) | Built-in | MiniSearch | Cloud |
|
||||
| **AI context (`llms.txt`)** | **Built-in** | - | - | - | Built-in |
|
||||
| **MCP server** | **Built-in** | - | - | - | Built-in |
|
||||
| **Agent skills** | **Built-in** | - | - | - | - |
|
||||
| **Docker image** | **Official** | - | Official | - | - |
|
||||
| **Self-hosted** | **Yes** | Yes | Yes | Yes | - |
|
||||
| **Cost** | **Free (OSS)** | Free (OSS) | Free (OSS) | Free (OSS) | Freemium |
|
||||
|
||||
## Features
|
||||
|
||||
### Zero config, instant start
|
||||
Point docmd at any Markdown folder and it runs. Navigation is built automatically from your file structure. You can write your first doc and have it live in under a minute — no boilerplate, no build pipeline to configure, no decisions to make upfront.
|
||||
|
||||
### Tiny by default, fast everywhere
|
||||
The default JavaScript payload is ~18 kb. Pages navigate as an instant SPA. The output is static HTML — SEO-optimised, with sitemap, canonical URLs, and Open Graph metadata included. Offline full-text search is built in, no cloud service required.
|
||||
|
||||
### AI-native
|
||||
docmd is built for the way documentation is read and used today:
|
||||
- **MCP Server** — `docmd mcp` exposes your docs to AI agents over stdio, letting them search, read, and validate content directly.
|
||||
- **Context (`llms.txt` / `llms-full.txt`)** — complete documentation context generated at build time, ready for any LLM.
|
||||
- **Agent Skills** — modular instruction sets for LLMs and IDE agents ([docmd-skills](https://github.com/docmd-io/docmd-skills)).
|
||||
- **Copy as Markdown / Copy Context** — one-click buttons in the browser, optimised for pasting into AI chat.
|
||||
|
||||
### Built to scale
|
||||
- Internationalisation with multi-locale builds
|
||||
- Versioning for multiple doc releases
|
||||
- Workspaces for monorepos and multi-project setups
|
||||
- Plugin system for extending core behaviour
|
||||
- Full theming support, built-in templates, custom CSS/JS, light/dark mode
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
docmd dev # local development server
|
||||
docmd build # build for deployment
|
||||
docmd live # browser-based Live Editor
|
||||
docmd migrate # import from Docusaurus, VitePress, MkDocs, or Starlight
|
||||
docmd deploy # generate config for Docker, NGINX, Caddy, Vercel, Netlify
|
||||
docmd validate # check all internal links
|
||||
docmd mcp # run as an MCP server over stdio
|
||||
docmd add <name> # install a plugin or template
|
||||
```
|
||||
|
||||
## Plugins
|
||||
|
||||
Core functionality is powered by a robust plugin system. The essentials are included by default, while optional plugins can be added for specific needs.
|
||||
|
||||
| Plugin | Status | Description |
|
||||
| :--- | :---: | :--- |
|
||||
| `search` | ✅ Core | Offline full-text search with fuzzy matching |
|
||||
| `seo` | ✅ Core | SEO tags and Open Graph metadata |
|
||||
| `sitemap` | ✅ Core | Generates `sitemap.xml` |
|
||||
| `git` | ✅ Core | Git commit history and last-updated dates |
|
||||
| `analytics` | ✅ Core | Lightweight analytics integration |
|
||||
| `llms` | ✅ Core | AI context generation (`llms.txt` / `llms-full.txt`) |
|
||||
| `mermaid` | ✅ Core | Mermaid diagram support |
|
||||
| `openapi` | ✅ Core | Build-time OpenAPI 3.x spec renderer |
|
||||
| `pwa` | ➕ Optional | Progressive Web App — offline navigation |
|
||||
| `threads` | ➕ Optional | Inline discussion threads *(by @svallory)* |
|
||||
| `math` | ➕ Optional | KaTeX / LaTeX math rendering |
|
||||
|
||||
Install optional plugins:
|
||||
|
||||
```bash
|
||||
docmd add <plugin-name>
|
||||
```
|
||||
|
||||
Build your own: [Plugin Development Guide](https://docs.docmd.io/development/building-plugins/)
|
||||
|
||||
## Configuration
|
||||
|
||||
No configuration is required to get started. Add a `docmd.config.json` (or `.ts` / `.js`) in your project root only when you need more control:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "My Project",
|
||||
"url": "https://docs.myproject.com",
|
||||
"src": "./docs",
|
||||
"out": "./dist"
|
||||
}
|
||||
```
|
||||
|
||||
TypeScript and JavaScript config files are supported for dynamic values.
|
||||
|
||||
Full reference: [Configuration Overview](https://docs.docmd.io/configuration/overview)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
my-docs/
|
||||
├── docs/ ← Your markdown files
|
||||
├── assets/ ← Images and static files
|
||||
├── docmd.config.json ← Optional configuration
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Live Editor
|
||||
|
||||
A browser-based editor for writing and previewing docs — no local setup required.
|
||||
|
||||
<p>
|
||||
<img alt="docmd live editor preview" width="820" src="https://docs.docmd.io/assets/previews/live-editor-preview.webp">
|
||||
</p>
|
||||
|
||||
**Try it at [live.docmd.io](https://live.docmd.io)**
|
||||
|
||||
## Programmatic API
|
||||
|
||||
Use docmd in Node.js scripts, CI pipelines, or custom build steps. (Supports both CommonJS and ESM).
|
||||
|
||||
```javascript
|
||||
import { build } from '@docmd/core';
|
||||
|
||||
await build('./docmd.config.json', { isDev: false });
|
||||
```
|
||||
|
||||
Full reference: [Node API](https://docs.docmd.io/development/node-api-reference/)
|
||||
|
||||
## Community
|
||||
|
||||
- **Bugs & issues** → [GitHub Issues](https://github.com/docmd-io/docmd/issues)
|
||||
- **Questions & ideas** → [Discussions](https://github.com/orgs/docmd-io/discussions)
|
||||
- **Contributing** → [CONTRIBUTING.md](.github/CONTRIBUTING.md)
|
||||
- **Roadmap** → [GitHub Discussions](https://github.com/orgs/docmd-io/discussions/2)
|
||||
|
||||
## Support
|
||||
|
||||
- Getting the word out is the most direct way to support docmd's development. [Share it on X](https://twitter.com/intent/tweet?url=https://github.com/docmd-io/docmd&text=docmd%20-%20Production-ready%20docs%20from%20Markdown%20in%20seconds.) with friends or give it a star.
|
||||
- If docmd saves you time, a [GitHub sponsorship](https://github.com/sponsors/mgks) goes a long way.
|
||||
- Got ideas or bugs? Open an issue or PR, feel free to contribute your own plugins.
|
||||
|
||||
## License
|
||||
|
||||
MIT License. See `LICENSE` for details.
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"name": "@docmd/core",
|
||||
"version": "0.8.12",
|
||||
"description": "Build production-ready documentation from Markdown in seconds. No React, no bloat, just content.",
|
||||
"type": "module",
|
||||
"browser": false,
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"bin": {
|
||||
"docmd": "dist/bin/docmd.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"postbuild": "chmod +x dist/bin/docmd.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docmd/api": "workspace:*",
|
||||
"@docmd/deployer": "workspace:*",
|
||||
"@docmd/live": "workspace:*",
|
||||
"@docmd/parser": "workspace:*",
|
||||
"@docmd/plugin-analytics": "workspace:*",
|
||||
"@docmd/plugin-git": "workspace:*",
|
||||
"@docmd/plugin-installer": "workspace:*",
|
||||
"@docmd/plugin-llms": "workspace:*",
|
||||
"@docmd/plugin-mermaid": "workspace:*",
|
||||
"@docmd/plugin-okf": "workspace:*",
|
||||
"@docmd/plugin-openapi": "workspace:*",
|
||||
"@docmd/plugin-search": "workspace:*",
|
||||
"@docmd/plugin-seo": "workspace:*",
|
||||
"@docmd/plugin-sitemap": "workspace:*",
|
||||
"@docmd/themes": "workspace:*",
|
||||
"@docmd/tui": "workspace:*",
|
||||
"@docmd/ui": "workspace:*",
|
||||
"@docmd/utils": "workspace:*",
|
||||
"esbuild": "^0.28.1",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.3"
|
||||
},
|
||||
"keywords": [
|
||||
"docmd",
|
||||
"markdown",
|
||||
"minimalist",
|
||||
"zero-config",
|
||||
"site-generator",
|
||||
"static-site-generator",
|
||||
"documentation",
|
||||
"typescript",
|
||||
"javascript",
|
||||
"nodejs",
|
||||
"browser",
|
||||
"website",
|
||||
"generator",
|
||||
"hosted",
|
||||
"cli",
|
||||
"ssg",
|
||||
"docs"
|
||||
],
|
||||
"author": {
|
||||
"name": "Ghazi",
|
||||
"url": "https://mgks.dev"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/docmd-io/docmd.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/docmd-io/docmd/issues"
|
||||
},
|
||||
"homepage": "https://docmd.io",
|
||||
"funding": "https://github.com/sponsors/mgks",
|
||||
"license": "MIT"
|
||||
}
|
||||
Executable
+313
@@ -0,0 +1,313 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* @package @docmd/core (and ecosystem)
|
||||
* @website https://docmd.io
|
||||
* @repository https://github.com/docmd-io/docmd
|
||||
* @license MIT
|
||||
* @copyright Copyright (c) 2025-present docmd.io
|
||||
*
|
||||
* [docmd-source] - Please do not remove this header.
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { parseArgs } from 'node:util';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { initProject } from '../commands/init.js';
|
||||
import { buildSite } from '../commands/build.js';
|
||||
import { startDevServer } from '../commands/dev.js';
|
||||
import { buildLive } from '../commands/live.js';
|
||||
import { migrateProject } from '../commands/migrate.js';
|
||||
import { stopServer } from '../commands/stop.js';
|
||||
import { initDeploy } from '../commands/deploy.js';
|
||||
import { runMcpServer } from '../commands/mcp.js';
|
||||
import { validateProject } from '../commands/validate.js';
|
||||
|
||||
import { TUI } from '@docmd/api';
|
||||
import { installPlugin, removePlugin } from '@docmd/plugin-installer';
|
||||
|
||||
const pkgUrl = new URL('../../package.json', import.meta.url);
|
||||
const { version } = JSON.parse(readFileSync(pkgUrl, 'utf-8'));
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
const options = {
|
||||
config: { type: 'string', short: 'c' },
|
||||
offline: { type: 'boolean' },
|
||||
port: { type: 'string', short: 'p' },
|
||||
'build-only': { type: 'boolean' },
|
||||
cwd: { type: 'string' },
|
||||
verbose: { type: 'boolean', short: 'V' },
|
||||
version: { type: 'boolean', short: 'v' },
|
||||
help: { type: 'boolean', short: 'h' },
|
||||
force: { type: 'boolean' },
|
||||
yes: { type: 'boolean', short: 'y' },
|
||||
json: { type: 'boolean' }
|
||||
} as const;
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseArgs({ args, options, allowPositionals: true, strict: false });
|
||||
} catch (e: any) {
|
||||
console.error(`Error: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { values, positionals } = parsed;
|
||||
|
||||
// Handle custom working directory
|
||||
if (values.cwd) {
|
||||
try {
|
||||
process.chdir(values.cwd);
|
||||
} catch (err: any) {
|
||||
console.error(`Error: Could not change directory to "${values.cwd}": ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const command = positionals[0];
|
||||
|
||||
if (values.version || (!command && args.includes('-v'))) {
|
||||
console.log(version);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!command || values.help) {
|
||||
TUI.banner(undefined, version);
|
||||
TUI.info(`Usage: docmd <command> [options]\n`);
|
||||
|
||||
TUI.section('Commands');
|
||||
const cmds = [
|
||||
['init', 'Initialize a new documentation project'],
|
||||
['build', 'Build the static site for production'],
|
||||
['dev', 'Start the development server'],
|
||||
['live', 'Launch the Live Editor'],
|
||||
['migrate', 'Migrate from Docusaurus, MkDocs, VitePress, etc.'],
|
||||
['deploy', 'Generate production deployment configurations'],
|
||||
['stop', 'Kill all running background docmd dev servers'],
|
||||
['mcp', 'Run docmd as a Model Context Protocol (MCP) server'],
|
||||
['validate', 'Validate documentation files and check for broken relative links'],
|
||||
['add', 'Install and configure a docmd plugin'],
|
||||
['remove', 'Remove and unconfigure a docmd plugin'],
|
||||
['doctor', 'Pre-flight check: report missing plugins, broken configs, mismatched engines (--fix to auto-install)']
|
||||
];
|
||||
cmds.forEach(([c, d]) => TUI.item(c, d, TUI.cyan));
|
||||
|
||||
TUI.section('Options');
|
||||
const optsList = [
|
||||
['-c, --config', 'Path to config (default: docmd.config.js)'],
|
||||
['-p, --port', 'Port to run server'],
|
||||
['--offline', 'Optimise for file:// viewing'],
|
||||
['--build-only', 'Generate dist/ without starting server'],
|
||||
['-V, --verbose', 'Show detailed package manager logs'],
|
||||
['-v, --version', 'Output the version number'],
|
||||
['-h, --help', 'Display help for command'],
|
||||
['--force', 'Overwrite existing files (use with init)'],
|
||||
['-y, --yes', 'Answer "yes" to all prompts (use with init in CI)']
|
||||
];
|
||||
optsList.forEach(([o, d]) => TUI.item(o, d, TUI.cyan));
|
||||
|
||||
TUI.section('Deploy Options');
|
||||
TUI.item('--docker', 'Generate Dockerfile & .dockerignore', TUI.cyan);
|
||||
TUI.item('--nginx', 'Generate production nginx.conf', TUI.cyan);
|
||||
TUI.item('--caddy', 'Generate production Caddyfile', TUI.cyan);
|
||||
TUI.item('--github-pages', 'Generate GitHub Actions deploy workflow', TUI.cyan);
|
||||
TUI.item('--vercel', 'Generate vercel.json', TUI.cyan);
|
||||
TUI.item('--netlify', 'Generate netlify.toml', TUI.cyan);
|
||||
TUI.footer();
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const opts = {
|
||||
config: values.config || 'docmd.config.js',
|
||||
offline: values.offline,
|
||||
port: values.port,
|
||||
buildOnly: values['build-only'],
|
||||
verbose: values.verbose,
|
||||
force: values.force,
|
||||
json: values.json
|
||||
};
|
||||
|
||||
// `--verbose` is a global flag: it must be honoured by every command,
|
||||
// not just `add` / `remove`. Setting DOCMD_VERBOSE in the process
|
||||
// environment gives every command (including third-party plugins, the
|
||||
// TUI, and future commands) a uniform, opt-in way to surface detailed
|
||||
// logs without having to thread `opts.verbose` through every call site.
|
||||
// The structured `opts.verbose` is still kept and forwarded to the
|
||||
// commands that already accept it (installPlugin, removePlugin).
|
||||
if (opts.verbose) {
|
||||
process.env.DOCMD_VERBOSE = 'true';
|
||||
}
|
||||
|
||||
if (command !== 'stop' && !values.json) {
|
||||
TUI.banner(undefined, version);
|
||||
}
|
||||
|
||||
if (command === 'init') {
|
||||
initProject({ force: values.force, yes: values.yes });
|
||||
} else if (command === 'build') {
|
||||
buildSite(opts.config, { isDev: false, offline: opts.offline, verbose: opts.verbose });
|
||||
} else if (command === 'dev') {
|
||||
startDevServer(opts.config, opts);
|
||||
} else if (command === 'live') {
|
||||
buildLive({ serve: !opts.buildOnly, verbose: opts.verbose }).catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
} else if (command === 'migrate') {
|
||||
const migrateArgs = args.slice(args.indexOf('migrate') + 1);
|
||||
const migrateOptions = {
|
||||
docusaurus: { type: 'boolean' },
|
||||
mkdocs: { type: 'boolean' },
|
||||
vitepress: { type: 'boolean' },
|
||||
starlight: { type: 'boolean' },
|
||||
upgrade: { type: 'boolean' },
|
||||
'dry-run': { type: 'boolean' },
|
||||
help: { type: 'boolean', short: 'h' }
|
||||
} as const;
|
||||
|
||||
let migrateParsed;
|
||||
try {
|
||||
migrateParsed = parseArgs({ args: migrateArgs, options: migrateOptions, allowPositionals: false });
|
||||
} catch (e: any) {
|
||||
console.log(`\nArgument needed. Please specify a migration source.`);
|
||||
console.log(`\nSources:`);
|
||||
console.log(` --docusaurus Migrate from Docusaurus`);
|
||||
console.log(` --mkdocs Migrate from MkDocs`);
|
||||
console.log(` --vitepress Migrate from VitePress`);
|
||||
console.log(` --starlight Migrate from Astro Starlight`);
|
||||
console.log(` --upgrade Upgrade legacy docmd configuration to modern schema`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const mv = migrateParsed.values;
|
||||
if (mv.help) {
|
||||
// `--help` is a successful no-op — print help and exit 0.
|
||||
console.log(`\nUsage: docmd migrate [options]\n`);
|
||||
console.log(`Sources:`);
|
||||
console.log(` --docusaurus Migrate from Docusaurus`);
|
||||
console.log(` --mkdocs Migrate from MkDocs`);
|
||||
console.log(` --vitepress Migrate from VitePress`);
|
||||
console.log(` --starlight Migrate from Astro Starlight`);
|
||||
console.log(` --upgrade Upgrade legacy docmd configuration to modern schema`);
|
||||
process.exit(0);
|
||||
}
|
||||
if (!mv.docusaurus && !mv.mkdocs && !mv.vitepress && !mv.starlight && !mv.upgrade) {
|
||||
// Phase 3 PR 3.A (F6): `docmd migrate` with no source is a usage error
|
||||
// — the user forgot to tell us what to migrate from. Print the same
|
||||
// help as `--help` but exit 1 so CI pipelines gate on it.
|
||||
console.log(`\nMissing configuration: please specify a migration source.\n`);
|
||||
console.log(`Usage: docmd migrate [options]\n`);
|
||||
console.log(`Sources:`);
|
||||
console.log(` --docusaurus Migrate from Docusaurus`);
|
||||
console.log(` --mkdocs Migrate from MkDocs`);
|
||||
console.log(` --vitepress Migrate from VitePress`);
|
||||
console.log(` --starlight Migrate from Astro Starlight`);
|
||||
console.log(` --upgrade Upgrade legacy docmd configuration to modern schema`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
migrateProject({
|
||||
docusaurus: mv.docusaurus,
|
||||
mkdocs: mv.mkdocs,
|
||||
vitepress: mv.vitepress,
|
||||
starlight: mv.starlight,
|
||||
upgrade: mv.upgrade,
|
||||
dryRun: mv['dry-run'] === true
|
||||
}).catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
} else if (command === 'deploy') {
|
||||
// Deploy has its own scoped flags - re-parse argv with deploy-specific options
|
||||
const deployArgs = args.slice(args.indexOf('deploy') + 1); // everything after "deploy"
|
||||
const deployOptions = {
|
||||
docker: { type: 'boolean' },
|
||||
nginx: { type: 'boolean' },
|
||||
caddy: { type: 'boolean' },
|
||||
'github-pages': { type: 'boolean' },
|
||||
vercel: { type: 'boolean' },
|
||||
netlify: { type: 'boolean' },
|
||||
force: { type: 'boolean' },
|
||||
help: { type: 'boolean', short: 'h' }
|
||||
} as const;
|
||||
|
||||
let deployParsed;
|
||||
try {
|
||||
deployParsed = parseArgs({ args: deployArgs, options: deployOptions, allowPositionals: false });
|
||||
} catch (e: any) {
|
||||
console.log(`\nArgument needed. Please specify a deployment target.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const dv = deployParsed.values;
|
||||
|
||||
if (dv.help) {
|
||||
console.log(`\nUsage: docmd deploy [options]\n`);
|
||||
console.log(`Self-hosted:`);
|
||||
console.log(` --docker Generate Dockerfile & .dockerignore`);
|
||||
console.log(` --nginx Generate production nginx.conf`);
|
||||
console.log(` --caddy Generate production Caddyfile`);
|
||||
console.log(`\nCloud / CI:`);
|
||||
console.log(` --github-pages Generate GitHub Actions deploy workflow`);
|
||||
console.log(` --vercel Generate vercel.json`);
|
||||
console.log(` --netlify Generate netlify.toml`);
|
||||
console.log(`\nOptions:`);
|
||||
console.log(` --force Overwrite existing deployment files`);
|
||||
console.log(` -h, --help Show this help message`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
initDeploy({
|
||||
docker: dv.docker,
|
||||
nginx: dv.nginx,
|
||||
caddy: dv.caddy,
|
||||
githubPages: dv['github-pages'],
|
||||
vercel: dv.vercel,
|
||||
netlify: dv.netlify,
|
||||
force: dv.force,
|
||||
config: opts.config
|
||||
}).catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
} else if (command === 'stop') {
|
||||
stopServer(opts.port, opts.force);
|
||||
} else if (command === 'add') {
|
||||
if (!positionals[1]) {
|
||||
console.error('Error: missing plugin name.');
|
||||
process.exit(1);
|
||||
}
|
||||
installPlugin(positionals[1], opts);
|
||||
} else if (command === 'remove') {
|
||||
if (!positionals[1]) {
|
||||
console.error('Error: missing plugin name.');
|
||||
process.exit(1);
|
||||
}
|
||||
removePlugin(positionals[1], opts);
|
||||
} else if (command === 'doctor') {
|
||||
const { runDoctor } = await import('../commands/doctor.js');
|
||||
const fix = values.fix === true;
|
||||
const asJson = values.json === true;
|
||||
const configPath = typeof values.config === 'string' ? values.config : undefined;
|
||||
const code = await runDoctor({ configPath, fix, json: asJson });
|
||||
if (code !== 0) process.exit(code);
|
||||
} else if (command === 'mcp') {
|
||||
runMcpServer().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
} else if (command === 'validate') {
|
||||
validateProject({ json: opts.json }).catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
} else {
|
||||
console.error(`Unknown command: ${command}`);
|
||||
console.log(`\nRun 'docmd --help' for the list of available commands.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* @package @docmd/core (and ecosystem)
|
||||
* @website https://docmd.io
|
||||
* @repository https://github.com/docmd-io/docmd
|
||||
* @license MIT
|
||||
* @copyright Copyright (c) 2025-present docmd.io
|
||||
*
|
||||
* [docmd-source] - Please do not remove this header.
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import nativeFs from 'fs';
|
||||
import { fsUtils as fs, WorkerPool } from '@docmd/utils';
|
||||
import { loadConfig } from '../utils/config-loader.js';
|
||||
import { TUI, loadPlugins, getPluginLoadErrors } from '@docmd/api';
|
||||
import { flushNormaliserWarnings, setNormaliserVerbose } from '@docmd/parser';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
import { prepareAssets, prepareTemplateAssets } from '../engine/assets.js';
|
||||
import { buildLocales, generateLocaleRedirect, preCountPages } from '../engine/i18n.js';
|
||||
import { NOT_FOUND_DEFAULTS } from '../utils/config-schema.js';
|
||||
|
||||
// Core package version — threaded through to renderPages for the
|
||||
// <meta name="generator"> tag so it stays in sync with @docmd/core.
|
||||
const _pkgUrl = new URL('../../package.json', import.meta.url);
|
||||
const pkg = JSON.parse(nativeFs.readFileSync(_pkgUrl, 'utf8')) as { version: string };
|
||||
|
||||
export async function buildSite(configPath: string, opts: any = {}) {
|
||||
|
||||
// Defaults to prevent ReferenceErrors
|
||||
const options = {
|
||||
isDev: opts.isDev || false,
|
||||
offline: opts.offline || false,
|
||||
quiet: opts.quiet || false,
|
||||
showStats: opts.showStats || false, // Show version/locale stats even when quiet
|
||||
onProgress: opts.onProgress || null, // External progress callback
|
||||
targetFiles: opts.targetFiles || null, // Optional: only rebuild specific files
|
||||
verbose: opts.verbose === true || process.env.DOCMD_VERBOSE === 'true',
|
||||
// D-H1: honour an explicit `cwd` override. Previously the build always
|
||||
// used `process.cwd()`, so calling `buildSite('/abs/path/config.json')`
|
||||
// from a different cwd would still look for `docs/` and `site/` next
|
||||
// to the caller's cwd. The fix honours `opts.cwd` when supplied, and
|
||||
// otherwise defaults to `path.dirname(configPath)` so the build
|
||||
// naturally follows the config file's location.
|
||||
cwd: opts.cwd || path.dirname(configPath) || process.cwd()
|
||||
};
|
||||
|
||||
// Per-warning normaliser logging is opt-in via --verbose / DOCMD_VERBOSE
|
||||
// so dev-server output stays quiet while CI / verbose builds get the
|
||||
// full breakdown of every container-normaliser issue.
|
||||
if (options.verbose) setNormaliserVerbose(true);
|
||||
|
||||
const CWD = options.cwd;
|
||||
|
||||
// ── Multi-Project (Workspace) Detection ──────────────────────────
|
||||
// If we're NOT already inside a workspace build (no env var set),
|
||||
// check if the root config has workspace settings.
|
||||
//
|
||||
// Phase 3 PR 3.C (F8): this block is wrapped in a try/catch so that
|
||||
// workspace validation errors (duplicate prefix, missing source
|
||||
// directory, no root project) are surfaced via `TUI.error` and exit
|
||||
// 1, rather than bubbling up as a raw JS stack trace with exit 0
|
||||
// (the `buildWorkspace` call itself throws plain `Error` objects
|
||||
// that nothing else catches).
|
||||
if (!process.env.DOCMD_PROJECT_OUT) {
|
||||
try {
|
||||
const { detectWorkspace, buildWorkspace } = await import('../engine/workspace.js');
|
||||
const workspaceConfig = await detectWorkspace(configPath);
|
||||
if (workspaceConfig) {
|
||||
await buildWorkspace(workspaceConfig, options);
|
||||
return;
|
||||
}
|
||||
} catch (wsErr: any) {
|
||||
if (!options.isDev && !options.quiet) {
|
||||
TUI.error('Workspace validation failed', wsErr.message || String(wsErr));
|
||||
if (process.env.npm_lifecycle_event === 'test' || process.env.CI) {
|
||||
console.error(wsErr.stack);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
throw wsErr;
|
||||
}
|
||||
}
|
||||
|
||||
// Start build timer
|
||||
const elapsed = TUI.timer();
|
||||
|
||||
// 1. Load Config (Zero-Config aware)
|
||||
try {
|
||||
const config = await loadConfig(configPath, { isDev: options.isDev, _globalDefaults: opts._globalDefaults, cwd: options.cwd });
|
||||
config._workspace = opts._workspace || null;
|
||||
config._activePrefix = opts._activePrefix || '/';
|
||||
config._globalDefaults = opts._globalDefaults || null;
|
||||
|
||||
// Initialize global WorkerPool (or use provided one)
|
||||
const workerScript = path.resolve(__dirname, '../engine/worker-parser.js');
|
||||
const workerPool = opts.workerPool || new WorkerPool(workerScript, { config, cwd: process.cwd() });
|
||||
config._workerPool = workerPool;
|
||||
|
||||
const hooks = await loadPlugins(config, { resolvePaths: [__dirname] });
|
||||
|
||||
// Phase 3 PR 3.A (F6): a plugin the user listed in `config.plugins` but
|
||||
// which failed to load is a build failure, not a warning. Without this
|
||||
// check, `docmd build` exits 0 even when a plugin is missing — the
|
||||
// site still builds but the user has no way to know a plugin was
|
||||
// dropped unless they read the warning text.
|
||||
// N-12: list each failed plugin by name and reason in the TUI, so
|
||||
// the operator doesn't have to dig through the error message string.
|
||||
const loadErrors = getPluginLoadErrors();
|
||||
if (loadErrors.length > 0) {
|
||||
if (!options.isDev && !options.quiet) {
|
||||
TUI.error(`${loadErrors.length} plugin(s) could not be loaded`, '');
|
||||
for (const e of loadErrors) {
|
||||
console.error(` ${TUI.red('•')} ${e.plugin} — ${e.message}`);
|
||||
}
|
||||
}
|
||||
const lines = loadErrors.map((e) => `${e.plugin}: ${e.message}`);
|
||||
throw new Error(
|
||||
`Build failed: ${loadErrors.length} plugin(s) could not be loaded:\n` +
|
||||
lines.map((l) => ` - ${l}`).join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
// Execute onConfigResolved hooks
|
||||
for (const fn of hooks.onConfigResolved) {
|
||||
await fn(config);
|
||||
}
|
||||
|
||||
const buildHash = Date.now().toString(36);
|
||||
const _buildId = `${buildHash}-${Math.random().toString(36).slice(2,7)}`;
|
||||
|
||||
// Use V3 labels (config.out / config.src) which are normalized by config-schema
|
||||
const rootOutputDir = path.resolve(CWD, config.out);
|
||||
await fs.ensureDir(rootOutputDir);
|
||||
|
||||
// ── TUI: Build section header ──────────────────────────
|
||||
if (!options.quiet) {
|
||||
TUI.section('Build');
|
||||
const details = TUI.extractProjectDetails(config, rootOutputDir, CWD);
|
||||
TUI.projectDetails(details);
|
||||
TUI.footer(); // close Build — Data Indexing and progress appear in clean air
|
||||
}
|
||||
|
||||
// Helper: Build Assets for a specific output directory
|
||||
const buildAssetsForDir = async (targetOutDir: string) => {
|
||||
await prepareAssets(config, targetOutDir, options);
|
||||
// New in 0.8.7: copy template assets (CSS/JS bundles shipped by
|
||||
// template plugins) into `assets/template/`.
|
||||
await prepareTemplateAssets(config, targetOutDir);
|
||||
if (hooks.assets) {
|
||||
for (const getAssetsFn of hooks.assets) {
|
||||
// hooks.assets entries are async wrappers; missing the await here
|
||||
// would make `assets` a Promise and silently skip the whole copy
|
||||
// loop (Array.isArray(Promise) === false). The user-visible symptom
|
||||
// is "plugin assets never land in site/" — search, git, mermaid,
|
||||
// math, openapi CSS/JS all missing, search modal does not open.
|
||||
const assets = await getAssetsFn();
|
||||
if (Array.isArray(assets)) {
|
||||
for (const asset of assets) {
|
||||
// Backwards-compat: legacy assets used `src`/`dest` and
|
||||
// `location`. The new typed `Asset` interface uses `path` and
|
||||
// `position`. Accept both spellings here.
|
||||
const src = (asset as any).src ?? (asset as any).path;
|
||||
const dest = (asset as any).dest ?? (asset as any).url;
|
||||
if (src && dest) {
|
||||
const destPath = path.join(targetOutDir, dest);
|
||||
await fs.ensureDir(path.dirname(destPath));
|
||||
await fs.copy(src, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Build assets ONCE for the root site (skip on targeted incremental rebuilds)
|
||||
if (!options.targetFiles) {
|
||||
await buildAssetsForDir(rootOutputDir);
|
||||
}
|
||||
|
||||
// Open Data Indexing before buildLocales so git (onBeforeBuild) runs inside it.
|
||||
// Search (indexing phase below) is appended to the same open section.
|
||||
const INDEXING_PLUGINS = new Set(['search']);
|
||||
const indexingHooks = hooks.onPostBuild.filter((fn: any) => INDEXING_PLUGINS.has(fn._pluginName));
|
||||
const publishingHooks = hooks.onPostBuild.filter((fn: any) => !INDEXING_PLUGINS.has(fn._pluginName));
|
||||
const hasIndexingWork = !options.targetFiles && (
|
||||
(hooks.onBeforeBuild?.length ?? 0) > 0 || indexingHooks.length > 0
|
||||
);
|
||||
if (hasIndexingWork && !options.quiet) TUI.section('Data Indexing', TUI.blue);
|
||||
|
||||
const allGeneratedPages = await buildLocales({
|
||||
config,
|
||||
rootOutputDir,
|
||||
hooks,
|
||||
buildHash,
|
||||
options: { ...options, _buildId } as any,
|
||||
CWD,
|
||||
onProgress: options.onProgress,
|
||||
targetFiles: options.targetFiles,
|
||||
coreVersion: pkg.version
|
||||
});
|
||||
|
||||
// --- i18n ROOT REDIRECT ---
|
||||
await generateLocaleRedirect(config, rootOutputDir);
|
||||
|
||||
// --- i18n PAGE MANIFEST ---
|
||||
// Emit a tiny JS file mapping locale IDs to their available page paths.
|
||||
// The client-side language switcher uses this for instant page-existence
|
||||
// checks - zero HEAD fetches, works offline, CDN-agnostic.
|
||||
if (config.i18n && config.i18n.locales) {
|
||||
const defaultLocale = config.i18n.default || '';
|
||||
const localeIds = new Set(config.i18n.locales.map((l: any) => l.id));
|
||||
const manifest: Record<string, string[]> = {};
|
||||
|
||||
for (const page of allGeneratedPages) {
|
||||
const segments = page.outputPath.split('/');
|
||||
const firstSeg = segments[0];
|
||||
let localeId = defaultLocale;
|
||||
let pagePath: string;
|
||||
|
||||
if (localeIds.has(firstSeg) && firstSeg !== defaultLocale) {
|
||||
localeId = firstSeg;
|
||||
pagePath = '/' + segments.slice(1).join('/');
|
||||
} else {
|
||||
pagePath = '/' + page.outputPath;
|
||||
}
|
||||
|
||||
// Normalize: /index.html → /, /foo/index.html → /foo
|
||||
pagePath = pagePath.replace(/\/index\.html$/, '') || '/';
|
||||
|
||||
if (!manifest[localeId]) manifest[localeId] = [];
|
||||
manifest[localeId].push(pagePath);
|
||||
}
|
||||
|
||||
const manifestJs = `window.DOCMD_LOCALE_PAGES=${JSON.stringify(manifest)};`;
|
||||
const manifestPath = path.join(rootOutputDir, 'assets', 'js', 'docmd-i18n-manifest.js');
|
||||
await fs.ensureDir(path.dirname(manifestPath));
|
||||
await fs.writeFile(manifestPath, manifestJs);
|
||||
}
|
||||
|
||||
// --- 3. GENERATE CUSTOM 404 PAGE ---
|
||||
// The 404 page always lives at <rootOutputDir>/404.html — a single
|
||||
// file at the site root, regardless of how many locales the site has.
|
||||
// Static hosts (Vercel, Netlify, GitHub Pages, Cloudflare Pages,
|
||||
// S3+CloudFront) all auto-serve /404.html on any unmatched route, so
|
||||
// emitting one per locale under subdirectories would either be ignored
|
||||
// or require a manual try_files rule per deployment.
|
||||
//
|
||||
// The page is translated to the site's DEFAULT locale. Visitors on a
|
||||
// non-default locale will still see the default-locale 404 — that's
|
||||
// a deliberate trade-off (one file at root > per-locale subdirs that
|
||||
// most hosts won't pick up). Users who want per-locale 404s can:
|
||||
// 1. Mount a custom 404 via config.notFound (full control), or
|
||||
// 2. Configure their reverse proxy with try_files fallbacks.
|
||||
const { renderTemplateAsync } = await import('@docmd/parser/dist/html-renderer.js');
|
||||
const ui = await import('@docmd/ui');
|
||||
|
||||
// Resolve default locale (falls back to 'en' when no i18n configured).
|
||||
const defaultLocaleId =
|
||||
(config.i18n?.default as string)
|
||||
|| (Array.isArray(config.i18n?.locales) && config.i18n.locales[0]?.id)
|
||||
|| 'en';
|
||||
const activeLocale = (Array.isArray(config.i18n?.locales)
|
||||
? config.i18n.locales.find((l: any) => l.id === defaultLocaleId)
|
||||
: null) || { id: defaultLocaleId };
|
||||
|
||||
const notFoundStrings = ui.loadTranslations(defaultLocaleId);
|
||||
const t = ui.createT(notFoundStrings);
|
||||
|
||||
// Resolution order for the 404 title and body:
|
||||
// 1. User-supplied config.notFound.title / config.notFound.content
|
||||
// (always wins when present — full customisation escape hatch).
|
||||
// 2. Translated via t('pageNotFound') / t('pageNotFoundMsg') against
|
||||
// the default locale's strings (zh, de, fr, ja, etc).
|
||||
// 3. NOT_FOUND_DEFAULTS.title / content (defined in config-schema)
|
||||
// as the final English fallback if a translation key is missing.
|
||||
const resolvedTitle = config.notFound?.title
|
||||
|| t('pageNotFound')
|
||||
|| NOT_FOUND_DEFAULTS.title;
|
||||
const resolvedContent = config.notFound?.content
|
||||
|| t('pageNotFoundMsg')
|
||||
|| NOT_FOUND_DEFAULTS.content;
|
||||
|
||||
const notFoundTemplatePath = path.join(ui.getTemplatesDir(), '404.ejs');
|
||||
let notFoundTemplateStr = '';
|
||||
if (await fs.exists(notFoundTemplatePath)) {
|
||||
notFoundTemplateStr = await fs.readFile(notFoundTemplatePath, 'utf8');
|
||||
} else {
|
||||
notFoundTemplateStr = `<h1>404</h1><p>Page Not Found</p>`;
|
||||
}
|
||||
|
||||
const themeInitPath = path.join(ui.getTemplatesDir(), 'partials', 'theme-init.js');
|
||||
const themeInitScript = (await fs.exists(themeInitPath)) ? `<script>${await fs.readFile(themeInitPath, 'utf8')}</script>` : '';
|
||||
|
||||
// Determine Absolute Base (usually '/' unless 'base' config is set)
|
||||
const absoluteRoot = config.base && config.base !== '/' ? config.base.replace(/\/$/, '') + '/' : '/';
|
||||
|
||||
const full404Html = await renderTemplateAsync(notFoundTemplateStr, {
|
||||
pageTitle: resolvedTitle,
|
||||
title: resolvedTitle,
|
||||
content: resolvedContent,
|
||||
logo: config.logo,
|
||||
t,
|
||||
activeLocale,
|
||||
|
||||
// Context for Assets
|
||||
relativePathToRoot: absoluteRoot,
|
||||
buildHash,
|
||||
appearance: config.theme?.appearance || config.theme?.defaultMode || 'system',
|
||||
defaultMode: config.theme?.appearance || config.theme?.defaultMode || 'system',
|
||||
theme: config.theme,
|
||||
customCssFiles: config.theme.customCss || [],
|
||||
|
||||
faviconLinkHtml: config.favicon ? `<link rel="icon" href="${absoluteRoot}${config.favicon.replace(/^\//, '')}">` : '',
|
||||
themeInitScript
|
||||
});
|
||||
|
||||
await fs.writeFile(path.join(rootOutputDir, '404.html'), full404Html);
|
||||
|
||||
// --- 4. GENERATE STATIC REDIRECTS ---
|
||||
if (config.redirects && Object.keys(config.redirects).length > 0) {
|
||||
for (const [from, to] of Object.entries(config.redirects)) {
|
||||
let cleanFrom = from.replace(/^\//, '');
|
||||
if (!cleanFrom.endsWith('.html')) cleanFrom = path.join(cleanFrom, 'index.html');
|
||||
|
||||
const redirectPath = path.join(rootOutputDir, cleanFrom);
|
||||
const redirectHtml = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Redirecting...</title><meta http-equiv="refresh" content="0; url=${to}"><link rel="canonical" href="${to}"><script>window.location.replace("${to}");</script></head><body><p>Redirecting to <a href="${to}">${to}</a>...</p></body></html>`;
|
||||
|
||||
await fs.ensureDir(path.dirname(redirectPath));
|
||||
await fs.writeFile(redirectPath, redirectHtml);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 5. Post Build Hooks ---
|
||||
// Only run on full builds. Split into:
|
||||
// Data Indexing → search (appended to already-open section from git above)
|
||||
// Publishing → sitemap, llms, pwa, etc.
|
||||
if (!options.targetFiles) {
|
||||
const postBuildCtx = {
|
||||
config,
|
||||
pages: allGeneratedPages,
|
||||
outputDir: rootOutputDir,
|
||||
log: (msg: string, status: 'DONE'|'SKIP'|'FAIL'|'WAIT' = 'DONE') => {
|
||||
if (!options.quiet) TUI.step(msg, status, TUI.blue);
|
||||
},
|
||||
tui: TUI,
|
||||
options: { ...options, quiet: options.quiet },
|
||||
runWorkerTask(modulePath: string, functionName: string, args: any[]) {
|
||||
if (!config._workerPool) throw new Error('WorkerPool is not initialized');
|
||||
return config._workerPool.runTask({ type: 'plugin-task', modulePath, functionName, args });
|
||||
}
|
||||
};
|
||||
|
||||
// Indexing — search runs in the already-open Data Indexing section
|
||||
for (const fn of indexingHooks) await fn(postBuildCtx);
|
||||
if (hasIndexingWork && !options.quiet) TUI.footer(TUI.blue);
|
||||
|
||||
// Publishing — each plugin renders as a parent line + indented children
|
||||
if (publishingHooks.length > 0) {
|
||||
if (!options.quiet) TUI.section('Publishing', TUI.blue);
|
||||
for (const fn of publishingHooks) {
|
||||
const pluginName = String((fn as any)._pluginName || 'plugin');
|
||||
const entries: Array<{ msg: string; status: 'DONE'|'SKIP'|'FAIL'|'WAIT' }> = [];
|
||||
const pluginCtx = {
|
||||
...postBuildCtx,
|
||||
log: (msg: string, status: 'DONE'|'SKIP'|'FAIL'|'WAIT' = 'DONE') => {
|
||||
entries.push({ msg, status });
|
||||
},
|
||||
};
|
||||
await fn(pluginCtx);
|
||||
if (!options.quiet) TUI.pluginTree(pluginName, entries, TUI.blue);
|
||||
}
|
||||
if (!options.quiet) TUI.footer(TUI.blue);
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.isDev && !options.quiet) {
|
||||
flushNormaliserWarnings();
|
||||
TUI.success(`Build complete. Generated ${allGeneratedPages.length} pages in ${elapsed()}.`);
|
||||
}
|
||||
|
||||
if (!opts.workerPool) {
|
||||
await workerPool.terminateAll();
|
||||
}
|
||||
|
||||
const { getPluginErrors } = await import('@docmd/api');
|
||||
const errors = getPluginErrors();
|
||||
if (errors.length > 0) {
|
||||
// N-12: surface every plugin error in one place. Previously the
|
||||
// user saw "Build complete" and only later discovered failures when
|
||||
// a generated page was missing or a downstream tool choked on a
|
||||
// bad artifact. The summary lists every error with its plugin +
|
||||
// hook, so the operator can grep for any of them.
|
||||
if (!options.isDev && !options.quiet) {
|
||||
TUI.error('Plugin errors during build', '');
|
||||
for (const err of errors) {
|
||||
const filePart = err.filePath ? ` (${err.filePath})` : '';
|
||||
console.error(` ${TUI.red('•')} ${err.plugin} :: ${err.hook}${filePart} — ${err.message}`);
|
||||
}
|
||||
}
|
||||
throw new Error(`Build failed: ${errors.length} plugin error(s) occurred during execution.`);
|
||||
}
|
||||
|
||||
} catch (e: any) {
|
||||
if (!options.isDev && !options.quiet) {
|
||||
TUI.error('Build failed', e.message);
|
||||
// Show full stack trace if we are in a testing/CI environment
|
||||
if (process.env.npm_lifecycle_event === 'test' || process.env.CI) {
|
||||
console.error(e.stack);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* @package @docmd/core (and ecosystem)
|
||||
* @website https://docmd.io
|
||||
* @repository https://github.com/docmd-io/docmd
|
||||
* @license MIT
|
||||
* @copyright Copyright (c) 2025-present docmd.io
|
||||
*
|
||||
* [docmd-source] - Please do not remove this header.
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { generateDeployConfigs } from '@docmd/deployer';
|
||||
import { loadConfig } from '../utils/config-loader.js';
|
||||
import { normalizeConfig } from '../utils/config-schema.js';
|
||||
import { TUI } from '@docmd/api';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const pkgUrl = new URL('../../package.json', import.meta.url);
|
||||
const { version } = JSON.parse(readFileSync(pkgUrl, 'utf-8'));
|
||||
|
||||
interface DeployFlags {
|
||||
docker?: boolean;
|
||||
nginx?: boolean;
|
||||
caddy?: boolean;
|
||||
githubPages?: boolean;
|
||||
vercel?: boolean;
|
||||
netlify?: boolean;
|
||||
force?: boolean;
|
||||
config?: string;
|
||||
}
|
||||
|
||||
async function resolveDeployContext(configPath: string) {
|
||||
let config: any;
|
||||
try {
|
||||
config = await loadConfig(configPath, { isDev: false, quiet: true });
|
||||
} catch {
|
||||
config = normalizeConfig({});
|
||||
}
|
||||
|
||||
const title = config.title;
|
||||
const outDir = config.out;
|
||||
const siteUrl = config.url ?? null;
|
||||
const isSpa = config.layout?.spa !== false;
|
||||
const configFile = configPath !== 'docmd.config.js' ? configPath : null;
|
||||
|
||||
let hostname = '';
|
||||
if (siteUrl) {
|
||||
try {
|
||||
hostname = new URL(siteUrl).hostname;
|
||||
} catch {
|
||||
hostname = siteUrl.replace(/^https?:\/\//, '').split('/')[0];
|
||||
}
|
||||
}
|
||||
|
||||
return { title, outDir, siteUrl, hostname, isSpa, configFile, version };
|
||||
}
|
||||
|
||||
export async function initDeploy(opts: DeployFlags) {
|
||||
const hasTarget = opts.docker || opts.nginx || opts.caddy || opts.githubPages || opts.vercel || opts.netlify;
|
||||
|
||||
if (!hasTarget) {
|
||||
TUI.section('Deployment Configuration');
|
||||
TUI.info('Please specify a target to configure:');
|
||||
console.log('');
|
||||
console.log(` Self-hosted`);
|
||||
console.log(` ${TUI.cyan('--docker')} Generate Dockerfile & .dockerignore`);
|
||||
console.log(` ${TUI.cyan('--nginx')} Generate production nginx.conf`);
|
||||
console.log(` ${TUI.cyan('--caddy')} Generate production Caddyfile`);
|
||||
console.log('');
|
||||
console.log(` Cloud / CI`);
|
||||
console.log(` ${TUI.cyan('--github-pages')} Generate GitHub Actions deploy workflow`);
|
||||
console.log(` ${TUI.cyan('--vercel')} Generate vercel.json`);
|
||||
console.log(` ${TUI.cyan('--netlify')} Generate netlify.toml`);
|
||||
console.log('');
|
||||
TUI.footer();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
const ctx = await resolveDeployContext(opts.config || 'docmd.config.js');
|
||||
TUI.section('Generating Deployment Configs');
|
||||
await generateDeployConfigs(ctx, opts);
|
||||
TUI.footer();
|
||||
TUI.success('Deployment configurations generated successfully!');
|
||||
TUI.info(`Remember to run ${TUI.cyan('docmd build')} first to generate your static site content.`);
|
||||
} catch (err: any) {
|
||||
TUI.error('Failed to generate deployment config', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* @package @docmd/core (and ecosystem)
|
||||
* @website https://docmd.io
|
||||
* @repository https://github.com/docmd-io/docmd
|
||||
* @license MIT
|
||||
* @copyright Copyright (c) 2025-present docmd.io
|
||||
*
|
||||
* [docmd-source] - Please do not remove this header.
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import nativeFs from 'node:fs';
|
||||
import path from 'path';
|
||||
import { fsUtils as fs, WorkerPool, FileSignatureTracker } from '@docmd/utils';
|
||||
import { createOriginVerify } from '../utils/ws-origin-guard.js';
|
||||
import { TUI } from '@docmd/api';
|
||||
import { buildSite } from './build.js';
|
||||
import { loadConfig } from '../utils/config-loader.js';
|
||||
import { createRequire } from 'module';
|
||||
import { createActionDispatcher, loadPlugins, hooks } from '@docmd/api';
|
||||
import {
|
||||
formatPathForDisplay, getNetworkIp, serveStatic, findAvailablePort, openBrowser,
|
||||
} from '../utils/dev-utils.js';
|
||||
|
||||
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// Main Dev Function
|
||||
export async function startDevServer(configPathOption: string, opts: any = {}) {
|
||||
const options = {
|
||||
preserve: opts.preserve || false,
|
||||
port: opts.port || undefined,
|
||||
};
|
||||
|
||||
// ── Multi-Project (Workspace) Detection ──────────────────────────
|
||||
if (!process.env.DOCMD_PROJECT_OUT) {
|
||||
const { detectWorkspace, devWorkspace } = await import('../engine/workspace.js');
|
||||
const workspaceConfig = await detectWorkspace(configPathOption);
|
||||
if (workspaceConfig) {
|
||||
await devWorkspace(workspaceConfig, options);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let config;
|
||||
try {
|
||||
config = await loadConfig(configPathOption, { isDev: true, quiet: true });
|
||||
} catch (e) {
|
||||
if (e.silent) {
|
||||
process.exit(0); // Exit gracefully if it's a known non-project folder error
|
||||
}
|
||||
// Config validation errors already print their details - exit cleanly
|
||||
if (e.message === 'Invalid configuration file.' || e.message?.startsWith('Error parsing config')) {
|
||||
TUI.error('Build failed', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
const CWD = process.cwd();
|
||||
|
||||
// Config Fallback Logic
|
||||
const actualConfigPath = config._resolvedPath || path.resolve(CWD, configPathOption);
|
||||
|
||||
const resolveConfigPaths = (currentConfig) => ({
|
||||
outputDir: path.resolve(CWD, currentConfig.out),
|
||||
srcDirToWatch: path.resolve(CWD, currentConfig.src),
|
||||
configFileToWatch: actualConfigPath,
|
||||
userAssetsDir: path.resolve(CWD, 'assets'),
|
||||
});
|
||||
|
||||
let paths = resolveConfigPaths(config);
|
||||
|
||||
// Create Server - uses a mutable reference so config restarts update the output dir
|
||||
const state = { outputDir: paths.outputDir };
|
||||
const server = http.createServer((req, res) => serveStatic(req, res, state.outputDir));
|
||||
let wss;
|
||||
|
||||
function broadcastReload() {
|
||||
if (wss) {
|
||||
wss.clients.forEach((client) => {
|
||||
if (client.readyState === WebSocket.OPEN) client.send('reload');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Initial Build ────────────────────────────────────
|
||||
const initialElapsed = TUI.timer();
|
||||
|
||||
const rootOutputDir = path.resolve(CWD, config.out || 'site');
|
||||
|
||||
let workerPool: WorkerPool;
|
||||
|
||||
try {
|
||||
const workerScript = path.resolve(__dirname, '../engine/worker-parser.js');
|
||||
workerPool = new WorkerPool(workerScript, { config, cwd: CWD });
|
||||
// Clean output dir before initial build to remove stale files from previous builds.
|
||||
// Without this, files generated under old URL structures (e.g. from a different
|
||||
// auto-router behaviour) persist alongside new files and cause 404 on nav links.
|
||||
if (await fs.exists(rootOutputDir)) {
|
||||
await fs.remove(rootOutputDir);
|
||||
}
|
||||
await buildSite(configPathOption, { isDev: true, preserve: options.preserve, quiet: false, showStats: false, workerPool });
|
||||
TUI.info(`Initial build completed in ${initialElapsed()}.`);
|
||||
} catch (error: any) {
|
||||
TUI.error('Initial build failed', error.message);
|
||||
}
|
||||
|
||||
// ── Watcher Setup ────────────────────────────────────
|
||||
const userAssetsDirExists = await fs.pathExists(paths.userAssetsDir);
|
||||
const configWatchPath = paths.configFileToWatch;
|
||||
const hasConfigFile = await fs.pathExists(configWatchPath);
|
||||
|
||||
TUI.section('Watching', TUI.blue);
|
||||
TUI.item('Source', formatPathForDisplay(paths.srcDirToWatch, CWD), TUI.dim, TUI.blue);
|
||||
if (hasConfigFile) {
|
||||
TUI.item('Config', formatPathForDisplay(configWatchPath, CWD), TUI.dim, TUI.blue);
|
||||
}
|
||||
if (userAssetsDirExists) {
|
||||
TUI.item('Assets', formatPathForDisplay(paths.userAssetsDir, CWD), TUI.dim, TUI.blue);
|
||||
}
|
||||
TUI.footer(TUI.blue);
|
||||
|
||||
const watchers: nativeFs.FSWatcher[] = [];
|
||||
let isRebuilding = false;
|
||||
let rebuildQueued = false;
|
||||
let rebuildTimeout: any = null;
|
||||
let lastContentRebuildAt = 0;
|
||||
let lastConfigRebuildAt = 0;
|
||||
// Quiet windows after a rebuild to absorb phantom fs.watch events that
|
||||
// macOS emits for newly-written nearby files. The primary guard is the
|
||||
// content-signature check (FileSignatureTracker) below — these windows
|
||||
// are just a secondary cushion.
|
||||
// - 1000ms after a content (markdown / asset) rebuild — quick to recover
|
||||
// - 2500ms after a full config rebuild — longer because the build
|
||||
// cascades through every project file
|
||||
const CONTENT_QUIET_MS = 1000;
|
||||
const CONFIG_QUIET_MS = 2500;
|
||||
// Tracks each watched file's mtime+size signature so phantom fs.watch
|
||||
// events that don't actually mutate the file are silently ignored.
|
||||
const signatureTracker = new FileSignatureTracker();
|
||||
|
||||
// Resolved output dir for robust exclusion (never retrigger on build output)
|
||||
const resolvedOutputDir = path.resolve(CWD, config.out || 'site');
|
||||
|
||||
let configLock = false;
|
||||
let configDebounce: any = null;
|
||||
async function reloadConfigAndRebuild(changedFilePath: string) {
|
||||
if (configLock) return;
|
||||
configLock = true;
|
||||
|
||||
const baseName = path.basename(changedFilePath);
|
||||
const configElapsed = TUI.timer();
|
||||
TUI.step(`Reloading config and rebuilding due to change in: ${baseName}`, 'WAIT', TUI.blue, true);
|
||||
|
||||
try {
|
||||
// Close all watchers
|
||||
watchers.forEach(w => w.close());
|
||||
watchers.length = 0;
|
||||
if (rebuildTimeout) { clearTimeout(rebuildTimeout); rebuildTimeout = null; }
|
||||
isRebuilding = false;
|
||||
rebuildQueued = false;
|
||||
|
||||
// Reload config
|
||||
config = await loadConfig(configPathOption, { isDev: true, quiet: true });
|
||||
paths = resolveConfigPaths(config);
|
||||
state.outputDir = paths.outputDir;
|
||||
|
||||
if (workerPool) await workerPool.terminateAll();
|
||||
const workerScript = path.resolve(__dirname, '../engine/worker-parser.js');
|
||||
workerPool = new WorkerPool(workerScript, { config, cwd: CWD });
|
||||
|
||||
// Full rebuild
|
||||
await buildSite(configPathOption, {
|
||||
isDev: true,
|
||||
preserve: options.preserve,
|
||||
quiet: true,
|
||||
workerPool
|
||||
});
|
||||
|
||||
lastConfigRebuildAt = Date.now();
|
||||
// Reset the signature tracker: after a full rebuild the file
|
||||
// mtimes on disk may have shifted, and we want the next user edit
|
||||
// to be detected as a fresh change rather than filtered as a
|
||||
// phantom duplicate.
|
||||
signatureTracker.reset();
|
||||
TUI.step(`Config reloaded and rebuilt in ${configElapsed()}`, 'DONE', TUI.blue, true);
|
||||
|
||||
// Re-setup watchers after the longer config quiet window so any
|
||||
// macOS phantom events from the build settle before we start
|
||||
// listening again.
|
||||
setTimeout(() => {
|
||||
setupContentWatchers();
|
||||
setupConfigWatcher();
|
||||
configLock = false;
|
||||
}, CONFIG_QUIET_MS);
|
||||
|
||||
broadcastReload();
|
||||
} catch (error: any) {
|
||||
TUI.step(`Config reload: ${baseName}`, 'FAIL', TUI.blue, true);
|
||||
TUI.error('Config reload failed', error.message);
|
||||
|
||||
// Re-setup watchers to continue listening. Use the same longer config
|
||||
// quiet window so phantom events fired during the failed reload
|
||||
// settle before we start listening again.
|
||||
setTimeout(() => {
|
||||
setupContentWatchers();
|
||||
setupConfigWatcher();
|
||||
configLock = false;
|
||||
}, CONFIG_QUIET_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function setupContentWatchers() {
|
||||
const contentPaths = [paths.srcDirToWatch];
|
||||
if (nativeFs.existsSync(paths.userAssetsDir)) contentPaths.push(paths.userAssetsDir);
|
||||
|
||||
if (process.env.DOCMD_DEV === 'true') {
|
||||
const DOCMD_ROOT = path.resolve(__dirname, '..');
|
||||
contentPaths.push(
|
||||
path.join(DOCMD_ROOT, 'templates'),
|
||||
path.join(DOCMD_ROOT, 'assets'),
|
||||
path.join(DOCMD_ROOT, 'engine'),
|
||||
path.join(DOCMD_ROOT, 'plugins'),
|
||||
path.join(DOCMD_ROOT, 'utils')
|
||||
);
|
||||
}
|
||||
|
||||
// Resolved output dir — used for robust exclusion below
|
||||
const resolvedOut = path.resolve(CWD, paths.outputDir);
|
||||
|
||||
for (const watchPath of contentPaths) {
|
||||
if (!nativeFs.existsSync(watchPath)) continue;
|
||||
|
||||
const watcher = nativeFs.watch(watchPath, { recursive: true }, (event, filename) => {
|
||||
if (!filename) return;
|
||||
|
||||
// Post-rebuild quiet period: swallow stale fs.watch events
|
||||
if (Date.now() - lastContentRebuildAt < CONTENT_QUIET_MS) return;
|
||||
if (Date.now() - lastConfigRebuildAt < CONFIG_QUIET_MS) return;
|
||||
|
||||
const filePath = path.resolve(watchPath, filename);
|
||||
|
||||
// Exclude build output dir (absolute path check — reliable across platforms)
|
||||
if (filePath.startsWith(resolvedOut + path.sep) || filePath === resolvedOut) return;
|
||||
|
||||
// Content-signature gate: ignore phantom events that don't actually
|
||||
// mutate the file (Spotlight reindex, iCloud sync, Time Machine,
|
||||
// macOS metadata reads, etc.). This is the primary defence.
|
||||
if (!signatureTracker.hasChanged(filePath)) return;
|
||||
|
||||
// Common noise exclusions
|
||||
if (
|
||||
filename.includes('.git') ||
|
||||
filename.includes('node_modules') ||
|
||||
filename.startsWith('.') ||
|
||||
filename.includes('.DS_Store')
|
||||
) return;
|
||||
|
||||
const relativeFilePath = path.relative(CWD, filePath);
|
||||
const isAsset = filePath.startsWith(path.resolve(paths.userAssetsDir));
|
||||
const isConfigOrNav =
|
||||
filename.includes('navigation.json') ||
|
||||
(filename.includes('docmd.config') && !filename.includes('docmd.config-'));
|
||||
|
||||
if (isConfigOrNav) {
|
||||
// Debounce config/nav reloads separately to collapse macOS multi-fire
|
||||
if (configDebounce) clearTimeout(configDebounce);
|
||||
configDebounce = setTimeout(() => {
|
||||
configDebounce = null;
|
||||
reloadConfigAndRebuild(filePath);
|
||||
}, 400);
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce: wait until user stops making changes before rebuilding.
|
||||
// Each new save resets this timer, so the build only runs after
|
||||
// the last change — like Vite's approach.
|
||||
if (rebuildTimeout) clearTimeout(rebuildTimeout);
|
||||
rebuildTimeout = setTimeout(() => {
|
||||
const executeBuildFn = async () => {
|
||||
if (isRebuilding) { rebuildQueued = true; return; }
|
||||
|
||||
const rebuildElapsed = TUI.timer();
|
||||
const sp = TUI.spinner(`Rebuilding: ${relativeFilePath}`, TUI.blue);
|
||||
isRebuilding = true;
|
||||
rebuildQueued = false;
|
||||
try {
|
||||
await buildSite(configPathOption, {
|
||||
isDev: true,
|
||||
preserve: options.preserve,
|
||||
quiet: true,
|
||||
targetFiles: isAsset ? undefined : [filePath],
|
||||
workerPool
|
||||
});
|
||||
lastContentRebuildAt = Date.now();
|
||||
sp.done(`Rebuilt: ${relativeFilePath} in ${rebuildElapsed()}`, true);
|
||||
broadcastReload();
|
||||
} catch (error: any) {
|
||||
sp.fail(`Rebuild: ${relativeFilePath}`, true);
|
||||
TUI.error('Rebuild failed', error.message);
|
||||
} finally {
|
||||
isRebuilding = false;
|
||||
if (rebuildQueued) executeBuildFn();
|
||||
}
|
||||
};
|
||||
executeBuildFn();
|
||||
}, 600); // 600ms: rebuild fires 600ms after user stops saving
|
||||
});
|
||||
watchers.push(watcher);
|
||||
}
|
||||
}
|
||||
|
||||
const setupConfigWatcher = () => {
|
||||
if (!hasConfigFile) return;
|
||||
let cfgDebounce: any = null;
|
||||
// Watch the config's parent directory, not the file directly — fs.watch
|
||||
// on a single file is the worst case on macOS and fires phantom events
|
||||
// from Spotlight / iCloud / Time Machine. Watching a directory and
|
||||
// filtering to our basename is dramatically more stable.
|
||||
const watchDir = path.dirname(configWatchPath);
|
||||
const configBaseName = path.basename(configWatchPath);
|
||||
|
||||
const configWatcher = nativeFs.watch(watchDir, (event, filename) => {
|
||||
if (!filename) return;
|
||||
// Only react to our specific config file (filter out sibling writes)
|
||||
if (filename !== configBaseName) return;
|
||||
|
||||
// Post-rebuild quiet period: a config reload closes+reopens
|
||||
// watchers, so any phantom events that fired during the rebuild are
|
||||
// already swallowed. This covers the brief window after reopen.
|
||||
if (Date.now() - lastConfigRebuildAt < CONFIG_QUIET_MS) return;
|
||||
|
||||
// Content-signature gate: ignore phantom events that don't actually
|
||||
// mutate the file. This is the primary defence — without it, a
|
||||
// single Spotlight reindex every few seconds would trigger a full
|
||||
// site rebuild indefinitely.
|
||||
if (!signatureTracker.hasChanged(configWatchPath)) return;
|
||||
|
||||
// Debounce: collapse the 3-6 rapid events macOS fires per save into one
|
||||
if (cfgDebounce) clearTimeout(cfgDebounce);
|
||||
cfgDebounce = setTimeout(() => {
|
||||
cfgDebounce = null;
|
||||
reloadConfigAndRebuild(configWatchPath);
|
||||
}, 500);
|
||||
});
|
||||
watchers.push(configWatcher);
|
||||
};
|
||||
|
||||
setupContentWatchers();
|
||||
setupConfigWatcher();
|
||||
|
||||
// Server Startup Logic
|
||||
const PORT = parseInt(options.port || process.env.PORT || 3000, 10);
|
||||
// Phase 1.D: default to loopback. Set DOCMD_HOST=0.0.0.0 to expose on LAN
|
||||
// (with the verifyClient callback below still guarding the Origin header).
|
||||
const BIND_HOST = process.env.DOCMD_HOST || '127.0.0.1';
|
||||
|
||||
function tryStartServer(port) {
|
||||
server.listen(port, BIND_HOST)
|
||||
.once('listening', async () => {
|
||||
if (BIND_HOST !== '127.0.0.1' && BIND_HOST !== '::1' && BIND_HOST !== 'localhost') {
|
||||
TUI.warn(`Dev server bound to ${BIND_HOST} (LAN). Any host able to reach this port can connect; verifyClient guards Origin.`);
|
||||
}
|
||||
// Phase 1.D: CWE-1385 CSWSH fix (N-S1). verifyClient validates the
|
||||
// Origin header against the loopback allowlist before accepting the
|
||||
// WebSocket handshake.
|
||||
wss = new WebSocketServer({
|
||||
server,
|
||||
verifyClient: createOriginVerify(),
|
||||
});
|
||||
wss.on('error', (e: any) => TUI.error('WebSocket Error', e.message));
|
||||
|
||||
// Action dispatcher for plugin actions/events
|
||||
await loadPlugins(config, { resolvePaths: [__dirname] });
|
||||
const dispatcher = createActionDispatcher(hooks, {
|
||||
projectRoot: CWD,
|
||||
config,
|
||||
broadcast: (event: string, data: any) => {
|
||||
wss.clients.forEach((client: any) => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(JSON.stringify({ type: 'event', name: event, data }));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Execute onDevServerReady hooks
|
||||
for (const fn of hooks.onDevServerReady) {
|
||||
await fn(server, wss);
|
||||
}
|
||||
|
||||
wss.on('connection', (ws: any) => {
|
||||
ws.on('message', async (raw: any) => {
|
||||
let msg: any;
|
||||
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
||||
|
||||
if (msg.type === 'call') {
|
||||
try {
|
||||
const { result } = await dispatcher.handleCall(msg.action, msg.payload);
|
||||
// Don't send reload flag to client - let the file watcher detect
|
||||
// the change, rebuild, and send the reload via broadcastReload()
|
||||
ws.send(JSON.stringify({ id: msg.id, type: 'response', result, reload: false }));
|
||||
} catch (e: any) {
|
||||
ws.send(JSON.stringify({ id: msg.id, type: 'response', error: e.message }));
|
||||
}
|
||||
} else if (msg.type === 'event') {
|
||||
dispatcher.handleEvent(msg.name, msg.data);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const indexHtmlPath = path.join(paths.outputDir, 'index.html');
|
||||
const networkIp = getNetworkIp();
|
||||
const localUrl = `http://127.0.0.1:${port}`;
|
||||
const networkUrl = networkIp ? `http://${networkIp}:${port}` : null;
|
||||
|
||||
TUI.section('Development Server Running', TUI.green);
|
||||
TUI.item('', '', TUI.dim, TUI.green);
|
||||
TUI.item('Local Access', localUrl, TUI.bold, TUI.green);
|
||||
if (networkUrl) {
|
||||
TUI.item('Network Access', networkUrl, TUI.bold, TUI.green);
|
||||
}
|
||||
TUI.item('Serving from', formatPathForDisplay(paths.outputDir, CWD), TUI.dim, TUI.green);
|
||||
// Show engine + locale/version summary — same details build shows (N-23).
|
||||
const devDetails = TUI.extractProjectDetails(config, paths.outputDir, CWD);
|
||||
if (devDetails.engine) TUI.item('Engine', devDetails.engine === 'rust' ? 'rust (preview)' : devDetails.engine, TUI.dim, TUI.green);
|
||||
if (devDetails.versions) TUI.item('Versions', `${devDetails.versions.count} (${devDetails.versions.labels})`, TUI.dim, TUI.green);
|
||||
if (devDetails.locales) TUI.item('Locales', `${devDetails.locales.count} (${devDetails.locales.labels})`, TUI.dim, TUI.green);
|
||||
TUI.item('','', TUI.dim, TUI.green);
|
||||
TUI.footer(TUI.green);
|
||||
|
||||
if (!await fs.pathExists(path.join(paths.outputDir, 'index.html'))) {
|
||||
TUI.warn('Root index.html not found. Build may be incomplete.');
|
||||
}
|
||||
|
||||
// Auto-launch localhost URL in default browser
|
||||
openBrowser(localUrl);
|
||||
})
|
||||
.once('error', (err: any) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
server.close();
|
||||
tryStartServer(port + 1);
|
||||
} else {
|
||||
TUI.error('Failed to start server', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Execution Flow
|
||||
(async () => {
|
||||
const finalPort = await findAvailablePort(PORT);
|
||||
tryStartServer(finalPort);
|
||||
})();
|
||||
|
||||
let isShuttingDown = false;
|
||||
|
||||
// Suppress ^C display and handle graceful shutdown
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdin.resume();
|
||||
process.stdin.on('data', (data) => {
|
||||
// Ctrl+C = 0x03
|
||||
if (data[0] === 0x03) {
|
||||
process.emit('SIGINT' as any);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// M-11: extracted graceful-shutdown logic so both SIGINT and SIGTERM
|
||||
// run the same cleanup. Previously SIGTERM called process.exit(0)
|
||||
// directly, which bypassed the watchers / wss / server / workerPool
|
||||
// shutdown path and could leave child processes or live sockets
|
||||
// hanging.
|
||||
async function gracefulShutdown() {
|
||||
if (isShuttingDown) return;
|
||||
isShuttingDown = true;
|
||||
|
||||
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
||||
|
||||
TUI.success('Shutting down...\n');
|
||||
|
||||
// Force exit after a shorter timeout if graceful shutdown hangs
|
||||
const forceExitTimeout = setTimeout(() => {
|
||||
process.exit(0);
|
||||
}, 500);
|
||||
forceExitTimeout.unref();
|
||||
|
||||
try {
|
||||
const closures: any[] = [];
|
||||
watchers.forEach(w => closures.push(new Promise<void>(resolve => { w.close(); resolve(); })));
|
||||
if (wss) closures.push(new Promise(resolve => wss.close(resolve)));
|
||||
if (server) closures.push(new Promise(resolve => server.close(resolve)));
|
||||
if (workerPool) closures.push(workerPool.terminateAll());
|
||||
|
||||
await Promise.all(closures);
|
||||
clearTimeout(forceExitTimeout);
|
||||
process.exit(0);
|
||||
} catch {
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
process.on('SIGINT', gracefulShutdown);
|
||||
process.on('SIGTERM', gracefulShutdown);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd doctor : pre-flight check command.
|
||||
*
|
||||
* Walks the user's config, checks every configured plugin and template
|
||||
* against the official registry, and prints a single status report. No
|
||||
* filesystem writes, no build side-effects — purely a diagnostic tool.
|
||||
*
|
||||
* Flags:
|
||||
* --fix Auto-install missing official plugins/templates.
|
||||
* --json Emit the report as JSON (for tooling).
|
||||
* --config <path> Path to docmd.config (defaults to the same search the
|
||||
* build commands use).
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — every check passed (or --fix resolved everything).
|
||||
* 1 — at least one problem remains (missing plugin, version mismatch,
|
||||
* broken manifest).
|
||||
* 2 — usage error.
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { TUI } from '@docmd/tui';
|
||||
import { loadConfig } from '../utils/config-loader.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
interface PluginReport {
|
||||
name: string;
|
||||
status: 'ok' | 'missing' | 'mismatch' | 'third-party' | 'unknown';
|
||||
declaredVersion?: string;
|
||||
installedVersion?: string;
|
||||
fixable: boolean;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
interface DoctorReport {
|
||||
config: string | null;
|
||||
core: { name: string; version: string; status: 'ok' | 'missing' };
|
||||
plugins: PluginReport[];
|
||||
template?: { requested: string; resolved: string; status: 'ok' | 'missing' };
|
||||
engines: Array<{ name: string; status: 'ok' | 'missing' | 'unknown' }>;
|
||||
autoInstallCandidates: string[];
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
interface DoctorOptions {
|
||||
configPath?: string;
|
||||
fix: boolean;
|
||||
json: boolean;
|
||||
}
|
||||
|
||||
export async function runDoctor(opts: DoctorOptions): Promise<number> {
|
||||
// 1. Resolve the config.
|
||||
const configPath = opts.configPath ? path.resolve(opts.configPath) : null;
|
||||
let config: any = {};
|
||||
let configError: string | null = null;
|
||||
if (configPath) {
|
||||
try {
|
||||
const loaded = await loadConfig(configPath);
|
||||
config = loaded?.config || loaded || {};
|
||||
} catch (e: any) {
|
||||
configError = e.message;
|
||||
}
|
||||
} else {
|
||||
// loadConfig requires an explicit path; we don't have a built-in
|
||||
// "find a config" helper yet. The user can re-run with --config.
|
||||
configError = 'No config path provided. Use --config <path> to point at docmd.config.{json,ts,js,mjs}.';
|
||||
}
|
||||
|
||||
// 2. Build the report.
|
||||
const report: DoctorReport = {
|
||||
config: configPath,
|
||||
core: { name: '@docmd/core', version: 'unknown', status: 'ok' },
|
||||
plugins: [],
|
||||
engines: [],
|
||||
autoInstallCandidates: [],
|
||||
warnings: [],
|
||||
errors: configError ? [configError] : [],
|
||||
};
|
||||
|
||||
// 3. Check @docmd/core version. We try a chain of strategies so the
|
||||
// doctor works in three real-world layouts:
|
||||
// (a) a real user install — `node_modules/@docmd/core/package.json`
|
||||
// is reachable via standard `createRequire` resolution.
|
||||
// (b) monorepo dev with a built dist — the build output sits under
|
||||
// `packages/core/dist/commands/`, and `import.meta.url` lets us
|
||||
// derive the package root and read the in-tree `package.json`.
|
||||
// (c) pnpm's symlinked `node_modules/@docmd/core` from inside the
|
||||
// playground — walk up from cwd and try every ancestor.
|
||||
//
|
||||
// If all strategies fail, we genuinely don't have `@docmd/core` on the
|
||||
// resolve path; we report that rather than guessing.
|
||||
report.core = resolveCorePackage();
|
||||
if (report.core.status === 'missing') {
|
||||
report.errors.push('@docmd/core is not installed');
|
||||
}
|
||||
|
||||
// 4. Check configured plugins.
|
||||
const configuredPlugins: Record<string, any> = (config.plugins || {});
|
||||
for (const [key, userOpts] of Object.entries(configuredPlugins)) {
|
||||
if (userOpts === false) continue;
|
||||
const pkgName = key.startsWith('@docmd/') ? key : `@docmd/plugin-${key}`;
|
||||
const installed = tryReadPackage(pkgName);
|
||||
if (installed) {
|
||||
report.plugins.push({
|
||||
name: key,
|
||||
status: 'ok',
|
||||
installedVersion: installed.version,
|
||||
fixable: false,
|
||||
});
|
||||
} else {
|
||||
report.plugins.push({
|
||||
name: key,
|
||||
status: 'missing',
|
||||
fixable: true,
|
||||
hint: `Run \`docmd add ${key}\` or add "${pkgName}" to your dependencies.`,
|
||||
});
|
||||
report.autoInstallCandidates.push(pkgName);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Check the active template.
|
||||
if (config.theme?.template) {
|
||||
const tplName = String(config.theme.template).trim();
|
||||
const tplPkg = tplName.startsWith('@docmd/') ? tplName : `@docmd/template-${tplName}`;
|
||||
const installed = tryReadPackage(tplPkg);
|
||||
report.template = {
|
||||
requested: tplName,
|
||||
resolved: tplPkg,
|
||||
status: installed ? 'ok' : 'missing',
|
||||
};
|
||||
if (!installed) {
|
||||
report.autoInstallCandidates.push(tplPkg);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Check engines. (The JS engine ships with core; the Rust engine is optional.)
|
||||
const requestedEngine = (config.engine || 'js').toLowerCase();
|
||||
report.engines.push({ name: 'js', status: 'ok' });
|
||||
if (requestedEngine === 'rust' || config.engines?.rust) {
|
||||
const rustInstalled = tryReadPackage('@docmd/engine-rust');
|
||||
report.engines.push({ name: 'rust', status: rustInstalled ? 'ok' : 'missing' });
|
||||
}
|
||||
|
||||
// 7. Optional auto-fix.
|
||||
if (opts.fix && report.autoInstallCandidates.length > 0) {
|
||||
const { execSync } = await import('node:child_process');
|
||||
const cwd = process.cwd();
|
||||
const pkgManager = detectPackageManager(cwd);
|
||||
const cmd = `${pkgManager} add ${report.autoInstallCandidates.join(' ')}`;
|
||||
try {
|
||||
execSync(cmd, { stdio: 'inherit', cwd, timeout: 180000 });
|
||||
} catch (e: any) {
|
||||
report.errors.push(`Auto-install failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Emit.
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
} else {
|
||||
printReport(report);
|
||||
}
|
||||
|
||||
return report.errors.length > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
function printReport(r: DoctorReport): void {
|
||||
console.log('');
|
||||
console.log('┌─ docmd pre-flight check');
|
||||
console.log('│');
|
||||
console.log(`│ ${r.core.name.padEnd(28)} ${r.core.version.padEnd(10)} ${r.core.status === 'ok' ? TUI.green('✓ installed') : TUI.red('✗ missing')}`);
|
||||
console.log('│');
|
||||
console.log('│ Configured plugins (' + r.plugins.length + ')');
|
||||
for (const p of r.plugins) {
|
||||
const sym = p.status === 'ok' ? TUI.green('✓') : p.status === 'missing' ? TUI.yellow('⚠') : TUI.dim('·');
|
||||
const ver = p.installedVersion ? ` (${p.installedVersion})` : '';
|
||||
console.log(`│ ${sym} ${p.name.padEnd(20)}${ver}${p.hint ? ' ' + TUI.dim(p.hint) : ''}`);
|
||||
}
|
||||
if (r.template) {
|
||||
const sym = r.template.status === 'ok' ? TUI.green('✓') : TUI.yellow('⚠');
|
||||
console.log(`│`);
|
||||
console.log(`│ Template`);
|
||||
console.log(`│ ${sym} ${r.template.resolved}${r.template.status === 'missing' ? ' ' + TUI.dim('not installed') : ''}`);
|
||||
}
|
||||
if (r.engines.length) {
|
||||
console.log('│');
|
||||
console.log('│ Engines');
|
||||
for (const e of r.engines) {
|
||||
const sym = e.status === 'ok' ? TUI.green('✓') : TUI.yellow('⚠');
|
||||
console.log(`│ ${sym} ${e.name}`);
|
||||
}
|
||||
}
|
||||
if (r.autoInstallCandidates.length) {
|
||||
console.log('│');
|
||||
console.log('│ ' + TUI.yellow(`Auto-install candidates (${r.autoInstallCandidates.length})`) + ' — run `docmd doctor --fix`');
|
||||
for (const c of r.autoInstallCandidates) {
|
||||
console.log(`│ • ${c}`);
|
||||
}
|
||||
}
|
||||
if (r.errors.length) {
|
||||
console.log('│');
|
||||
console.log('│ ' + TUI.red('Errors'));
|
||||
for (const e of r.errors) console.log(`│ ${e}`);
|
||||
}
|
||||
console.log('└──────────────────────────────────');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `@docmd/core`'s version with a chain of strategies. Returns
|
||||
* `{ name, version, status: 'ok' }` on success, or
|
||||
* `{ name, version: 'unknown', status: 'missing' }` if the package is
|
||||
* genuinely not on the resolve path.
|
||||
*
|
||||
* Strategy order:
|
||||
* 1. `createRequire(import.meta.url)` from the doctor file's URL —
|
||||
* this works for a real user install where the doctor runs from
|
||||
* `node_modules/@docmd/core/dist/commands/doctor.js`.
|
||||
* 2. Walk up from `process.cwd()` looking for a `node_modules/@docmd/core/package.json`
|
||||
* — this covers pnpm's symlinked layout from the playground and
|
||||
* yarn's hoisted layout in monorepos.
|
||||
* 3. Walk up from the doctor file's location looking for an in-tree
|
||||
* `package.json` whose `name` is `@docmd/core` — this covers the
|
||||
* monorepo dev case where the dist files are run directly without
|
||||
* being installed into a `node_modules` tree.
|
||||
*/
|
||||
function resolveCorePackage(): { name: string; version: string; status: 'ok' | 'missing' } {
|
||||
const FAIL = { name: '@docmd/core', version: 'unknown', status: 'missing' as const };
|
||||
const SEARCH_NAMES = new Set(['@docmd/core', 'docmd']);
|
||||
|
||||
// Strategy 1: createRequire from this file's URL.
|
||||
try {
|
||||
const req = createRequire(import.meta.url);
|
||||
const pkg = req('@docmd/core/package.json');
|
||||
return { name: pkg.name, version: pkg.version, status: 'ok' };
|
||||
} catch { /* fall through */ }
|
||||
|
||||
// Strategy 2: walk up from cwd looking for node_modules/@docmd/core/package.json.
|
||||
for (let dir = process.cwd(); dir !== path.parse(dir).root; dir = path.dirname(dir)) {
|
||||
const candidate = path.join(dir, 'node_modules', '@docmd', 'core', 'package.json');
|
||||
if (fs.existsSync(candidate)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(candidate, 'utf8'));
|
||||
if (SEARCH_NAMES.has(pkg.name)) {
|
||||
return { name: pkg.name, version: pkg.version, status: 'ok' };
|
||||
}
|
||||
} catch { /* try next ancestor */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: walk up from the doctor file's location looking for an
|
||||
// in-tree package.json whose name is @docmd/core. This is the monorepo
|
||||
// dev case where the dist files are run directly without being installed
|
||||
// into a node_modules tree.
|
||||
let dir = __dirname;
|
||||
while (dir !== path.parse(dir).root) {
|
||||
const candidate = path.join(dir, 'package.json');
|
||||
if (fs.existsSync(candidate)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(candidate, 'utf8'));
|
||||
if (SEARCH_NAMES.has(pkg.name)) {
|
||||
return { name: pkg.name, version: pkg.version, status: 'ok' };
|
||||
}
|
||||
} catch { /* try next ancestor */ }
|
||||
}
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
|
||||
return FAIL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a package's `package.json` from any of the three strategies above.
|
||||
* Used to detect configured plugins and engines.
|
||||
*/
|
||||
function tryReadPackage(name: string): { name: string; version: string } | null {
|
||||
// Strategy 1: createRequire from this file's URL.
|
||||
try {
|
||||
const req = createRequire(import.meta.url);
|
||||
const pkg = req(`${name}/package.json`);
|
||||
return { name: pkg.name, version: pkg.version };
|
||||
} catch { /* fall through */ }
|
||||
|
||||
// Strategy 2: walk up from cwd.
|
||||
for (let dir = process.cwd(); dir !== path.parse(dir).root; dir = path.dirname(dir)) {
|
||||
const candidate = path.join(dir, 'node_modules', ...name.split('/'), 'package.json');
|
||||
if (fs.existsSync(candidate)) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(candidate, 'utf8'));
|
||||
} catch { /* try next ancestor */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: walk up from the doctor file's location.
|
||||
let dir = __dirname;
|
||||
while (dir !== path.parse(dir).root) {
|
||||
const candidate = path.join(dir, 'node_modules', ...name.split('/'), 'package.json');
|
||||
if (fs.existsSync(candidate)) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(candidate, 'utf8'));
|
||||
} catch { /* try next ancestor */ }
|
||||
}
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectPackageManager(cwd: string): 'pnpm' | 'yarn' | 'bun' | 'npm' {
|
||||
let dir = cwd;
|
||||
const { existsSync } = require('node:fs') as typeof import('node:fs');
|
||||
const { join } = require('node:path') as typeof import('node:path');
|
||||
while (dir !== require('node:path').parse(dir).root) {
|
||||
if (existsSync(join(dir, 'pnpm-lock.yaml'))) return 'pnpm';
|
||||
if (existsSync(join(dir, 'yarn.lock'))) return 'yarn';
|
||||
if (existsSync(join(dir, 'bun.lockb'))) return 'bun';
|
||||
if (existsSync(join(dir, 'package-lock.json'))) return 'npm';
|
||||
dir = require('node:path').dirname(dir);
|
||||
}
|
||||
return 'npm';
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* @package @docmd/core (and ecosystem)
|
||||
* @website https://docmd.io
|
||||
* @repository https://github.com/docmd-io/docmd
|
||||
* @license MIT
|
||||
* @copyright Copyright (c) 2025-present docmd.io
|
||||
*
|
||||
* [docmd-source] - Please do not remove this header.
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { TUI } from '@docmd/api';
|
||||
import { fsUtils as fs } from '@docmd/utils';
|
||||
import path from 'path';
|
||||
import readline from 'readline';
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { version } = require('../../package.json');
|
||||
|
||||
const defaultConfigContent = `{
|
||||
"title": "My Documentation",
|
||||
"url": "https://docs.myproject.com",
|
||||
"src": "docs",
|
||||
"out": "site",
|
||||
"engine": "js",
|
||||
"layout": {
|
||||
"spa": true,
|
||||
"header": {
|
||||
"enabled": true
|
||||
},
|
||||
"sidebar": {
|
||||
"collapsible": true,
|
||||
"defaultCollapsed": false
|
||||
},
|
||||
"optionsMenu": {
|
||||
"position": "sidebar-top",
|
||||
"components": {
|
||||
"search": true,
|
||||
"themeSwitch": true
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"style": "minimal",
|
||||
"content": "© ${new Date().getFullYear()} My Project.",
|
||||
"branding": true
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"name": "default",
|
||||
"appearance": "system",
|
||||
"codeHighlight": true
|
||||
},
|
||||
"minify": true,
|
||||
"autoTitleFromH1": true,
|
||||
"copyCode": true,
|
||||
"pageNavigation": true,
|
||||
"navigation": [
|
||||
{ "title": "Quick Start", "path": "/", "icon": "zap" },
|
||||
{ "title": "Agent Skills", "path": "/skills/", "icon": "brain-circuit" },
|
||||
{
|
||||
"title": "Quick Guide",
|
||||
"icon": "book-open",
|
||||
"collapsible": false,
|
||||
"children": [
|
||||
{ "title": "Install", "path": "https://docs.docmd.io/getting-started/installation/", "icon": "download", "external": true },
|
||||
{ "title": "Configure", "path": "https://docs.docmd.io/configuration/overview/", "icon": "settings", "external": true },
|
||||
{ "title": "Migrate", "path": "https://docs.docmd.io/migration/overview/", "icon": "arrow-left-right", "external": true },
|
||||
{ "title": "Deploy", "path": "https://docs.docmd.io/deployment/", "icon": "rocket", "external": true }
|
||||
]
|
||||
},
|
||||
{ "title": "GitHub", "path": "https://github.com/docmd-io/docmd", "icon": "github", "external": true }
|
||||
],
|
||||
"plugins": {
|
||||
"git": {
|
||||
"commitHistory": true,
|
||||
"maxCommits": 5
|
||||
},
|
||||
"seo": {
|
||||
"defaultDescription": "Documentation built with docmd."
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const defaultIndexMdContent = `---
|
||||
title: "Quick Start"
|
||||
description: "Welcome to your new documentation site."
|
||||
---
|
||||
|
||||
# Quick Start Your Docs 🚀
|
||||
|
||||
This is the home page of your new **docmd** project. You're currently viewing \`docs/index.md\` — edit it, and your site updates.
|
||||
|
||||
## Run the dev server
|
||||
|
||||
\`\`\`bash
|
||||
npx @docmd/core dev
|
||||
\`\`\`
|
||||
|
||||
Open \`http://localhost:3000\` — the page auto-reloads as you edit.
|
||||
|
||||
## Build for production
|
||||
|
||||
\`\`\`bash
|
||||
npx @docmd/core build
|
||||
\`\`\`
|
||||
|
||||
Output goes to \`site/\`. Deploy that folder anywhere that serves static files.
|
||||
|
||||
## Project structure
|
||||
|
||||
\`\`\`text
|
||||
.
|
||||
├── docs/ # Your markdown content
|
||||
│ └── index.md # You are here
|
||||
├── assets/ # Custom CSS, JS, and images
|
||||
├── docmd.config.json # Site configuration
|
||||
└── package.json # Node dependencies + scripts
|
||||
\`\`\`
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Smart containers
|
||||
|
||||
\`\`\`markdown
|
||||
::: callout tip "Did you know?"
|
||||
You can nest containers, add titles, and use icons.
|
||||
:::
|
||||
|
||||
::: card "Flexible" icon:layout-grid
|
||||
Organise content with cards.
|
||||
|
||||
[View the docs →](https://docs.docmd.io){.docmd-button}
|
||||
:::
|
||||
\`\`\`
|
||||
|
||||
Renders as a styled callout and a card with a button.
|
||||
|
||||
### 2. Tabs and code
|
||||
|
||||
\`\`\`\`markdown
|
||||
::: tabs
|
||||
== tab "JavaScript" icon:braces
|
||||
\`\`\`javascript
|
||||
console.log('Hello World');
|
||||
\`\`\`
|
||||
|
||||
== tab "Python" icon:code
|
||||
\`\`\`python
|
||||
print('Hello World')
|
||||
\`\`\`
|
||||
:::
|
||||
\`\`\`\`
|
||||
|
||||
### 3. Built-in plugins
|
||||
|
||||
docmd ships with these plugins enabled by default — no install needed:
|
||||
|
||||
- **Search** — full-text + semantic search (optional)
|
||||
- **Sitemap** + **SEO** meta tags
|
||||
- **LLMs context** — \`llms.txt\` and \`llms.json\` for AI agents
|
||||
- **OKF** — Open Knowledge Format bundle at \`site/okf/\`
|
||||
- **Mermaid** diagrams
|
||||
- **Git** last-modified timestamps
|
||||
- **Math** (KaTeX) — enable with \`docmd add math\`
|
||||
|
||||
See the [full plugin list](https://docs.docmd.io/plugins/usage/).
|
||||
|
||||
## Next steps
|
||||
|
||||
- **[Install docmd](https://docs.docmd.io/getting-started/installation/)**
|
||||
- **[Configure your site](https://docs.docmd.io/configuration/overview/)**
|
||||
- **[Browse templates](https://docs.docmd.io/theming/templates/)**
|
||||
- **[Deploy to production](https://docs.docmd.io/deployment/)**
|
||||
- **[GitHub repo](https://github.com/docmd-io/docmd/)**
|
||||
|
||||
Happy documenting! 🎉`;
|
||||
|
||||
const defaultSkillsMdContent = `---
|
||||
title: "Agent Skills"
|
||||
description: "Teach AI coding agents to work with docmd projects"
|
||||
---
|
||||
|
||||
# Agent Skills
|
||||
|
||||
docmd ships a **modular skill set** for AI coding agents (Claude Code, Cursor, Windsurf, Copilot, etc.). The skills teach your agent the \`docmd\` CLI, configuration, plugin system, and the \`docmd mcp\` server — so it can build, configure, validate, and deploy sites for you.
|
||||
|
||||
## What gets installed
|
||||
|
||||
The [\`docmd-skills\`](https://www.npmjs.com/package/docmd-skills) npm package contains three skill modules:
|
||||
|
||||
| Skill | When your agent loads it |
|
||||
|---|---|
|
||||
| **\`docmd-skills\`** | A docmd **site operator**. Knows the \`npx @docmd/core\` CLI, \`docmd.config.json\`, plugins, themes, deployment, and the \`docmd mcp\` server. |
|
||||
| **\`docmd-dev\`** | A docmd **framework contributor**. Knows the monorepo layout, how to author plugins and templates, the JS / Rust engine loaders, and the public Node API (\`EngineLoader\`, \`createActionDispatcher\`, \`TemplateSlot\`). |
|
||||
| **\`docmd-writer\`** | A **multi-language documentation writer**. Drafts and reviews prose in any language, with SEO awareness and docmd's markdown conventions (containers, frontmatter, file-title rule). |
|
||||
|
||||
## Install
|
||||
|
||||
Pick the directory your agent reads skills from and run **one** command:
|
||||
|
||||
### Claude Code
|
||||
|
||||
\`\`\`bash
|
||||
npx docmd-skills ~/.claude/skills
|
||||
\`\`\`
|
||||
|
||||
### Cursor
|
||||
|
||||
\`\`\`bash
|
||||
npx docmd-skills ~/.cursor/skills
|
||||
\`\`\`
|
||||
|
||||
### Project-local (any agent)
|
||||
|
||||
\`\`\`bash
|
||||
npx docmd-skills ./.skills
|
||||
\`\`\`
|
||||
|
||||
After install, the \`docmd-skills\`, \`docmd-dev\`, and \`docmd-writer\` modules are available to your agent.
|
||||
|
||||
Run \`npx docmd-skills --help\` for the full list of commands.
|
||||
|
||||
## How it works at runtime
|
||||
|
||||
Once installed, your agent automatically loads the right skill based on the files in scope:
|
||||
|
||||
- When you ask "add a plugin to my docmd site" → \`docmd-skills\` loads
|
||||
- When you ask "add a new template" inside the cloned \`docmd-io/docmd\` monorepo → \`docmd-dev\` loads
|
||||
- When you ask "write the intro for my new docs" → \`docmd-writer\` loads
|
||||
|
||||
## Update or remove
|
||||
|
||||
The CLI is idempotent — running it again updates the skills to latest:
|
||||
|
||||
\`\`\`bash
|
||||
npx docmd-skills <dir> # updates all three
|
||||
npx docmd-skills remove <dir> # deletes all three
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
const defaultPackageJson = {
|
||||
name: "my-docs",
|
||||
version: "0.0.1",
|
||||
private: true,
|
||||
type: "module",
|
||||
scripts: {
|
||||
"dev": "docmd dev",
|
||||
"build": "docmd build",
|
||||
"preview": "npx serve site"
|
||||
},
|
||||
dependencies: {
|
||||
"@docmd/core": `^${version}`
|
||||
}
|
||||
};
|
||||
|
||||
export async function initProject(opts: { force?: boolean; yes?: boolean } = {}) {
|
||||
const baseDir = process.cwd();
|
||||
const packageJsonFile = path.join(baseDir, 'package.json');
|
||||
const configFile = path.join(baseDir, 'docmd.config.json');
|
||||
const docsDir = path.join(baseDir, 'docs');
|
||||
const indexMdFile = path.join(docsDir, 'index.md');
|
||||
const skillsMdFile = path.join(docsDir, 'skills.md');
|
||||
const skillFile = path.join(baseDir, 'SKILL.md');
|
||||
const assetsDir = path.join(baseDir, 'assets');
|
||||
const assetsCssDir = path.join(assetsDir, 'css');
|
||||
const assetsJsDir = path.join(assetsDir, 'js');
|
||||
const assetsImagesDir = path.join(assetsDir, 'images');
|
||||
|
||||
const existingFiles = [];
|
||||
const dirExists = {
|
||||
docs: false,
|
||||
assets: false
|
||||
};
|
||||
|
||||
TUI.section('Project Setup');
|
||||
|
||||
// Check if package.json exists
|
||||
if (!await fs.pathExists(packageJsonFile)) {
|
||||
await fs.writeJson(packageJsonFile, defaultPackageJson, { spaces: 2 });
|
||||
TUI.step('Created package.json', 'DONE');
|
||||
} else {
|
||||
TUI.step('Using existing package.json', 'SKIP');
|
||||
}
|
||||
|
||||
// Check each configuration file variant individually
|
||||
if (await fs.pathExists(configFile)) {
|
||||
existingFiles.push('docmd.config.json');
|
||||
}
|
||||
const jsConfigFile = path.join(baseDir, 'docmd.config.js');
|
||||
if (await fs.pathExists(jsConfigFile)) {
|
||||
existingFiles.push('docmd.config.js');
|
||||
}
|
||||
const tsConfigFile = path.join(baseDir, 'docmd.config.ts');
|
||||
if (await fs.pathExists(tsConfigFile)) {
|
||||
existingFiles.push('docmd.config.ts');
|
||||
}
|
||||
|
||||
// Check for the legacy config.js
|
||||
const oldConfigFile = path.join(baseDir, 'config.js');
|
||||
if (await fs.pathExists(oldConfigFile)) {
|
||||
existingFiles.push('config.js');
|
||||
}
|
||||
|
||||
// Check if docs directory exists
|
||||
if (await fs.pathExists(docsDir)) {
|
||||
dirExists.docs = true;
|
||||
if (await fs.pathExists(indexMdFile)) {
|
||||
existingFiles.push('docs/index.md');
|
||||
}
|
||||
if (await fs.pathExists(skillsMdFile)) {
|
||||
existingFiles.push('docs/skills.md');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if assets directory exists
|
||||
if (await fs.pathExists(assetsDir)) {
|
||||
dirExists.assets = true;
|
||||
}
|
||||
|
||||
// Determine if we should override existing files
|
||||
let shouldOverride = !!opts.force;
|
||||
if (existingFiles.length > 0 && !shouldOverride) {
|
||||
TUI.warn('Existing files detected:');
|
||||
existingFiles.forEach(file => TUI.item('', file));
|
||||
|
||||
// In a non-interactive environment (CI, piped input, Docker without -it,
|
||||
// npx with no TTY), skip the prompt. Default to "no" for safety unless
|
||||
// --yes is passed.
|
||||
const isInteractive = !!process.stdin.isTTY;
|
||||
if (!isInteractive) {
|
||||
if (opts.yes) {
|
||||
shouldOverride = true;
|
||||
TUI.dim(' (non-interactive mode + --yes: overriding existing files)');
|
||||
} else {
|
||||
shouldOverride = false;
|
||||
TUI.dim(' (non-interactive mode: keeping existing files. Pass --force to override.)');
|
||||
}
|
||||
} else {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
const answer = await new Promise(resolve => {
|
||||
rl.question(`\n ${TUI.bold('Do you want to override these files?')} (y/N): `, resolve);
|
||||
});
|
||||
|
||||
rl.close();
|
||||
|
||||
shouldOverride = (answer as string).toLowerCase() === 'y';
|
||||
}
|
||||
|
||||
if (!shouldOverride) {
|
||||
TUI.step('Maintaining existing files', 'SKIP');
|
||||
}
|
||||
} else if (existingFiles.length > 0 && shouldOverride) {
|
||||
TUI.warn(`Overriding ${existingFiles.length} existing file(s) (--force)`);
|
||||
}
|
||||
|
||||
// Create docs directory if it doesn't exist
|
||||
if (!dirExists.docs) {
|
||||
await fs.ensureDir(docsDir);
|
||||
TUI.step('Created docs/ directory', 'DONE');
|
||||
} else {
|
||||
TUI.step('Using existing docs/ directory', 'SKIP');
|
||||
}
|
||||
|
||||
// Create assets directory structure if it doesn't exist
|
||||
if (!dirExists.assets) {
|
||||
await fs.ensureDir(assetsDir);
|
||||
await fs.ensureDir(assetsCssDir);
|
||||
await fs.ensureDir(assetsJsDir);
|
||||
await fs.ensureDir(assetsImagesDir);
|
||||
TUI.step('Created assets/ infrastructure', 'DONE');
|
||||
} else {
|
||||
TUI.step('Using existing assets/ directory', 'SKIP');
|
||||
if (!await fs.pathExists(assetsCssDir)) await fs.ensureDir(assetsCssDir);
|
||||
if (!await fs.pathExists(assetsJsDir)) await fs.ensureDir(assetsJsDir);
|
||||
if (!await fs.pathExists(assetsImagesDir)) await fs.ensureDir(assetsImagesDir);
|
||||
}
|
||||
|
||||
// Write config file if it doesn't exist or user confirmed override
|
||||
if (!await fs.pathExists(configFile) && !await fs.pathExists(jsConfigFile) && !await fs.pathExists(tsConfigFile) || shouldOverride) {
|
||||
await fs.writeFile(configFile, defaultConfigContent, 'utf8');
|
||||
TUI.step(`${shouldOverride ? 'Updated' : 'Created'} docmd.config.json`, 'DONE');
|
||||
} else {
|
||||
TUI.step('Using existing configuration', 'SKIP');
|
||||
}
|
||||
|
||||
// Write index.md file if it doesn't exist or user confirmed override
|
||||
if (!await fs.pathExists(indexMdFile)) {
|
||||
await fs.writeFile(indexMdFile, defaultIndexMdContent, 'utf8');
|
||||
TUI.step('Created docs/index.md', 'DONE');
|
||||
} else if (shouldOverride) {
|
||||
await fs.writeFile(indexMdFile, defaultIndexMdContent, 'utf8');
|
||||
TUI.step('Updated docs/index.md', 'DONE');
|
||||
} else {
|
||||
TUI.step('Using existing docs/index.md', 'SKIP');
|
||||
}
|
||||
|
||||
// Write skills.md (the Agent Skills docs page) if it doesn't exist or user confirmed override
|
||||
if (!await fs.pathExists(skillsMdFile)) {
|
||||
await fs.writeFile(skillsMdFile, defaultSkillsMdContent, 'utf8');
|
||||
TUI.step('Created docs/skills.md', 'DONE');
|
||||
} else if (shouldOverride) {
|
||||
await fs.writeFile(skillsMdFile, defaultSkillsMdContent, 'utf8');
|
||||
TUI.step('Updated docs/skills.md', 'DONE');
|
||||
} else {
|
||||
TUI.step('Using existing docs/skills.md', 'SKIP');
|
||||
}
|
||||
|
||||
// Write SKILL.md if it doesn't exist or user confirmed override
|
||||
if (!await fs.pathExists(skillFile) || shouldOverride) {
|
||||
TUI.dim(' Run `npx docmd-skills <dir>` to install the full agent skill set.');
|
||||
} else {
|
||||
TUI.step('Using existing SKILL.md', 'SKIP');
|
||||
}
|
||||
|
||||
TUI.footer();
|
||||
TUI.success('Initialisation complete. Run `npm install` to setup dependencies.');
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* @package @docmd/core (and ecosystem)
|
||||
* @website https://docmd.io
|
||||
* @repository https://github.com/docmd-io/docmd
|
||||
* @license MIT
|
||||
* @copyright Copyright (c) 2025-present docmd.io
|
||||
*
|
||||
* [docmd-source] - Please do not remove this header.
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
|
||||
import { fsUtils as fs } from '@docmd/utils';
|
||||
import path from 'path';
|
||||
import { TUI } from '@docmd/api';
|
||||
|
||||
export async function buildLive(options: any = {}) {
|
||||
// Delegate to the standalone package
|
||||
const livePkg = await import('@docmd/live');
|
||||
|
||||
// If explicitly asked NOT to serve (for testing), just build
|
||||
if (options.serve === false) {
|
||||
TUI.section('Building Live Editor');
|
||||
TUI.step('Compiling standalone runtime', 'WAIT');
|
||||
await livePkg.build(process.cwd());
|
||||
TUI.footer();
|
||||
} else {
|
||||
// Default behavior: Build + Serve
|
||||
await livePkg.start();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* @package @docmd/core (and ecosystem)
|
||||
* @website https://docmd.io
|
||||
* @repository https://github.com/docmd-io/docmd
|
||||
* @license MIT
|
||||
* @copyright Copyright (c) 2025-present docmd.io
|
||||
*
|
||||
* [docmd-source] - Please do not remove this header.
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import readline from 'readline';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { buildSite } from './build.js';
|
||||
import { loadConfig } from '../utils/config-loader.js';
|
||||
import { safePath, asUserPath } from '@docmd/utils';
|
||||
|
||||
const pkgUrl = new URL('../../package.json', import.meta.url);
|
||||
const { version } = JSON.parse(fs.readFileSync(pkgUrl, 'utf-8'));
|
||||
|
||||
// Helpers to find and search markdown files
|
||||
function findMarkdownFiles(dir: string): string[] {
|
||||
const results: string[] = [];
|
||||
try {
|
||||
const list = fs.readdirSync(dir);
|
||||
for (const file of list) {
|
||||
const fullPath = path.join(dir, file);
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (stat && stat.isDirectory()) {
|
||||
if (file !== 'node_modules' && !file.startsWith('.')) {
|
||||
results.push(...findMarkdownFiles(fullPath));
|
||||
}
|
||||
} else if (file.endsWith('.md') || file.endsWith('.markdown')) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
} catch { /* ignore filesystem errors */ }
|
||||
return results;
|
||||
}
|
||||
|
||||
export function validateLinks(docsDir: string): { file: string; line: number; link: string; error: string }[] {
|
||||
const errors: { file: string; line: number; link: string; error: string }[] = [];
|
||||
if (!fs.existsSync(docsDir)) return errors;
|
||||
|
||||
const mdFiles = findMarkdownFiles(docsDir);
|
||||
|
||||
for (const filePath of mdFiles) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
const relativeFile = path.relative(process.cwd(), filePath);
|
||||
|
||||
lines.forEach((lineText, lineIdx) => {
|
||||
const lineNum = lineIdx + 1;
|
||||
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
let match;
|
||||
while ((match = linkRegex.exec(lineText)) !== null) {
|
||||
const linkTarget = match[2].trim().split('#')[0]; // strip anchors
|
||||
if (!linkTarget) continue;
|
||||
|
||||
// Skip external, anchors, mailto, etc.
|
||||
if (
|
||||
linkTarget.startsWith('http://') ||
|
||||
linkTarget.startsWith('https://') ||
|
||||
linkTarget.startsWith('mailto:') ||
|
||||
linkTarget.startsWith('tel:') ||
|
||||
linkTarget.startsWith('#')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve link relative to file's directory
|
||||
let resolvedPath = '';
|
||||
if (linkTarget.startsWith('/')) {
|
||||
resolvedPath = path.join(docsDir, linkTarget);
|
||||
} else {
|
||||
resolvedPath = path.resolve(path.dirname(filePath), linkTarget);
|
||||
}
|
||||
|
||||
// Phase 3 PR 3.C (M-1): strip trailing slash before the
|
||||
// `.md` / `.markdown` / `index.*` existence checks. Most
|
||||
// markdown editors add a trailing slash to internal links
|
||||
// (e.g. `[page 2](/page-2/)`), and the previous code did
|
||||
// `fs.existsSync('docs/page-2/.md')` which is always false.
|
||||
// The build itself produces `page-2/index.html` and treats
|
||||
// the trailing-slash and no-slash forms as the same link,
|
||||
// so the validator MUST agree or it false-positives every
|
||||
// valid link.
|
||||
const stripped = resolvedPath.endsWith('/') && resolvedPath.length > 1
|
||||
? resolvedPath.slice(0, -1)
|
||||
: resolvedPath;
|
||||
|
||||
const exists =
|
||||
fs.existsSync(stripped) ||
|
||||
fs.existsSync(stripped + '.md') ||
|
||||
fs.existsSync(stripped + '.markdown') ||
|
||||
fs.existsSync(path.join(stripped, 'index.md')) ||
|
||||
fs.existsSync(path.join(stripped, 'index.markdown'));
|
||||
|
||||
if (!exists) {
|
||||
errors.push({
|
||||
file: relativeFile,
|
||||
line: lineNum,
|
||||
link: linkTarget,
|
||||
error: `Broken link: target resolved to ${path.relative(process.cwd(), resolvedPath)} does not exist`
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch { /* ignore read errors */ }
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export async function runMcpServer() {
|
||||
console.error("docmd MCP server starting...");
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: undefined,
|
||||
terminal: false
|
||||
});
|
||||
|
||||
const sendResponse = (id: any, result: any) => {
|
||||
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
|
||||
};
|
||||
|
||||
const sendError = (id: any, code: number, message: string, data?: any) => {
|
||||
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message, data } }) + "\n");
|
||||
};
|
||||
|
||||
rl.on('line', async (line) => {
|
||||
if (!line.trim()) return;
|
||||
try {
|
||||
const message = JSON.parse(line);
|
||||
if (message.jsonrpc !== "2.0") {
|
||||
sendError(message.id || null, -32600, "Invalid Request");
|
||||
return;
|
||||
}
|
||||
|
||||
const { method, params, id } = message;
|
||||
|
||||
if (method === "initialize") {
|
||||
sendResponse(id, {
|
||||
protocolVersion: "2025-03-26",
|
||||
capabilities: {
|
||||
resources: {},
|
||||
tools: {},
|
||||
prompts: {}
|
||||
},
|
||||
serverInfo: {
|
||||
name: "docmd-mcp-server",
|
||||
version
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "notifications/initialized" || method === "initialized") {
|
||||
// Notification, no response needed
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "ping") {
|
||||
sendResponse(id, {});
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Prompts Listing
|
||||
// The prompts capability is declared in initialize for forward
|
||||
// compatibility. docmd ships no built-in prompts yet, so return an
|
||||
// empty list rather than leaving the client to hit an unhandled-method error.
|
||||
if (method === "prompts/list") {
|
||||
sendResponse(id, { prompts: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Tools Listing
|
||||
if (method === "tools/list") {
|
||||
sendResponse(id, {
|
||||
tools: [
|
||||
{
|
||||
name: "search_docs",
|
||||
description: "Search the documentation content for a specific query.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "The term or phrase to search for." }
|
||||
},
|
||||
required: ["query"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "list_docs",
|
||||
description: "List all documentation files in the project. Returns relative paths so the agent can navigate the docs structure before reading individual files.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
subdir: { type: "string", "description": "Optional subdirectory to scope the listing (e.g. 'en', 'v1', 'guides'). Defaults to the configured source directory root." }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "read_doc",
|
||||
description: "Read the raw markdown content of a documentation file.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
route: { type: "string", description: "Relative path to the markdown file from root (e.g. docs/getting-started.md)." }
|
||||
},
|
||||
required: ["route"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "get_config",
|
||||
description: "Retrieve the resolved docmd project configuration (docmd.config.json). Exposes the source directory, output directory, configured locales, versions, and enabled plugins so the agent understands the project structure.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "validate_docs",
|
||||
description: "Validates and lints all local markdown links to detect broken paths.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "get_llms_context",
|
||||
description: "Retrieve the unified prompt context of the entire documentation site (llms.txt content).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Resources Listing
|
||||
if (method === "resources/list") {
|
||||
sendResponse(id, {
|
||||
resources: [
|
||||
{
|
||||
uri: "docmd://context/llms.txt",
|
||||
name: "llms.txt Context",
|
||||
mimeType: "text/plain",
|
||||
description: "Unified text context containing the entire documentation site"
|
||||
},
|
||||
{
|
||||
uri: "docmd://context/skill",
|
||||
name: "Agent SKILL.md",
|
||||
mimeType: "text/markdown",
|
||||
description: "Instruction manual and skills reference for docmd"
|
||||
}
|
||||
]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Resources Read
|
||||
if (method === "resources/read") {
|
||||
const { uri } = params || {};
|
||||
if (!uri) {
|
||||
sendResponse(id, { content: [{ type: "text", text: "Error: URI parameter is required." }] });
|
||||
return;
|
||||
}
|
||||
|
||||
let config: any;
|
||||
try {
|
||||
config = await loadConfig('docmd.config.js', { quiet: true });
|
||||
} catch {
|
||||
config = { src: 'docs', out: 'site' };
|
||||
}
|
||||
|
||||
if (uri === "docmd://context/llms.txt") {
|
||||
const llmsFullFile = path.resolve(process.cwd(), config.out || 'site', 'llms-full.txt');
|
||||
if (fs.existsSync(llmsFullFile)) {
|
||||
try {
|
||||
const fullContext = fs.readFileSync(llmsFullFile, 'utf8');
|
||||
sendResponse(id, {
|
||||
contents: [{
|
||||
uri,
|
||||
mimeType: "text/plain",
|
||||
text: fullContext
|
||||
}]
|
||||
});
|
||||
} catch (err: any) {
|
||||
sendResponse(id, { content: [{ type: "text", text: `Error reading context file: ${err.message}` }] });
|
||||
}
|
||||
} else {
|
||||
sendResponse(id, { content: [{ type: "text", text: "Error: llms-full.txt context file has not been generated." }] });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (uri === "docmd://context/skill") {
|
||||
const skillFile = path.resolve(process.cwd(), 'SKILL.md');
|
||||
let content = '';
|
||||
if (fs.existsSync(skillFile)) {
|
||||
try {
|
||||
content = fs.readFileSync(skillFile, 'utf8');
|
||||
} catch (err: any) {
|
||||
sendResponse(id, { content: [{ type: "text", text: `Error reading SKILL.md: ${err.message}` }] });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
content = [
|
||||
"---",
|
||||
"name: docmd",
|
||||
"description: Fallback agent instruction set for docmd.",
|
||||
"skills: docmd-skills (npm)",
|
||||
"docs: https://docs.docmd.io",
|
||||
"llms-context: https://docs.docmd.io/llms-full.txt",
|
||||
"---",
|
||||
"",
|
||||
"# docmd Agent Skills",
|
||||
"",
|
||||
"This project uses **docmd**, the zero-config AI-first documentation engine.",
|
||||
"",
|
||||
"## Get the full skill set (single-line install)",
|
||||
"",
|
||||
"Run this once per machine to install the full agent skill set into your agent's skills directory:",
|
||||
"",
|
||||
"```bash",
|
||||
"npx docmd-skills ~/.claude/skills",
|
||||
"```",
|
||||
"",
|
||||
"Replace `~/.claude/skills` with the directory your agent reads skills from: `~/.cursor/skills` (Cursor), `./.skills` (project-local), etc. Run `npx docmd-skills --help` for the full subcommand list. The single install command pulls in the `docmd-skills`, `docmd-dev`, and `docmd-writer` skill modules — see https://github.com/docmd-io/docmd-skills for details.",
|
||||
"",
|
||||
"## Project-local override",
|
||||
"This fallback content covers the general case. For project-specific instructions, create a `SKILL.md` at the root of the docmd project — `docmd mcp`'s `get_skill` tool returns that local file in preference to this fallback."
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
sendResponse(id, {
|
||||
contents: [{
|
||||
uri,
|
||||
mimeType: "text/markdown",
|
||||
text: content
|
||||
}]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendError(id, -32602, `Resource not found: ${uri}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Tools Execution
|
||||
if (method === "tools/call") {
|
||||
const { name, arguments: args } = params;
|
||||
|
||||
// Resolve workspace parameters
|
||||
let config: any;
|
||||
try {
|
||||
config = await loadConfig('docmd.config.js', { quiet: true });
|
||||
} catch {
|
||||
// Fallback zero config
|
||||
config = { src: 'docs', out: 'site' };
|
||||
}
|
||||
const docsDir = path.resolve(process.cwd(), config.src || 'docs');
|
||||
|
||||
if (name === "search_docs") {
|
||||
const query = (args?.query || "").toLowerCase();
|
||||
if (!query) {
|
||||
sendResponse(id, { content: [{ type: "text", text: "Error: Query parameter is required." }] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(docsDir)) {
|
||||
sendResponse(id, { content: [{ type: "text", text: `Error: Source directory "${docsDir}" does not exist.` }] });
|
||||
return;
|
||||
}
|
||||
|
||||
const mdFiles = findMarkdownFiles(docsDir);
|
||||
const matches: string[] = [];
|
||||
|
||||
for (const filePath of mdFiles) {
|
||||
try {
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const lines = fileContent.split('\n');
|
||||
const fileRelPath = path.relative(process.cwd(), filePath);
|
||||
let foundInFile = false;
|
||||
|
||||
lines.forEach((lineText, idx) => {
|
||||
if (lineText.toLowerCase().includes(query)) {
|
||||
if (!foundInFile) {
|
||||
matches.push(`\n### File: ${fileRelPath}`);
|
||||
foundInFile = true;
|
||||
}
|
||||
matches.push(`Line ${idx + 1}: ${lineText.trim()}`);
|
||||
}
|
||||
});
|
||||
} catch { /* ignore file errors */ }
|
||||
}
|
||||
|
||||
const textResult = matches.length > 0
|
||||
? `Found search matches:\n${matches.join('\n')}`
|
||||
: `No matches found for query: "${query}"`;
|
||||
|
||||
sendResponse(id, { content: [{ type: "text", text: textResult }] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === "list_docs") {
|
||||
const subdir = (args?.subdir || "").trim();
|
||||
let listRoot = docsDir;
|
||||
if (subdir) {
|
||||
// CWE-22 guard: same safePath treatment as read_doc.
|
||||
if (path.isAbsolute(subdir)) {
|
||||
sendResponse(id, { content: [{ type: "text", text: `Error: Absolute paths are not allowed.` }] });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
listRoot = safePath(docsDir, asUserPath(subdir));
|
||||
} catch {
|
||||
sendResponse(id, { content: [{ type: "text", text: `Error: Path "${subdir}" escapes the source directory.` }] });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!fs.existsSync(listRoot)) {
|
||||
sendResponse(id, { content: [{ type: "text", text: `Error: Directory "${path.relative(process.cwd(), listRoot)}" does not exist.` }] });
|
||||
return;
|
||||
}
|
||||
const files = findMarkdownFiles(listRoot).map(f => path.relative(process.cwd(), f));
|
||||
const sorted = files.sort();
|
||||
const textResult = sorted.length > 0
|
||||
? `Documentation files (${sorted.length}):\n${sorted.join('\n')}`
|
||||
: `No markdown files found under "${path.relative(process.cwd(), listRoot)}".`;
|
||||
sendResponse(id, { content: [{ type: "text", text: textResult }] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === "get_config") {
|
||||
try {
|
||||
const resolvedConfig = await loadConfig('docmd.config.js', { quiet: true });
|
||||
// Surface only the structural keys an agent needs. Omit
|
||||
// potentially sensitive values (API keys, analytics IDs) by
|
||||
// reconstructing a safe summary from the resolved config.
|
||||
const safeSummary: Record<string, any> = {
|
||||
title: resolvedConfig.title || 'Untitled',
|
||||
src: resolvedConfig.src || 'docs',
|
||||
out: resolvedConfig.out || 'site',
|
||||
url: resolvedConfig.url || null,
|
||||
theme: resolvedConfig.theme?.name || 'default',
|
||||
i18n: resolvedConfig.i18n
|
||||
? {
|
||||
default: resolvedConfig.i18n.default,
|
||||
locales: (resolvedConfig.i18n.locales || []).map((l: any) => ({ id: l.id, label: l.label, dir: l.dir }))
|
||||
}
|
||||
: null,
|
||||
versions: resolvedConfig.versions
|
||||
? {
|
||||
current: resolvedConfig.versions.current,
|
||||
all: (resolvedConfig.versions.all || []).map((v: any) => ({ id: v.id, dir: v.dir, label: v.label }))
|
||||
}
|
||||
: null,
|
||||
plugins: resolvedConfig.plugins
|
||||
? Object.keys(resolvedConfig.plugins).map(k => ({
|
||||
name: k,
|
||||
enabled: resolvedConfig.plugins[k] !== false
|
||||
}))
|
||||
: []
|
||||
};
|
||||
sendResponse(id, { content: [{ type: "text", text: JSON.stringify(safeSummary, null, 2) }] });
|
||||
} catch (err: any) {
|
||||
sendResponse(id, { content: [{ type: "text", text: `Error loading config: ${err.message}\n\nThe project may be running in zero-config mode (source: ./docs, output: ./site).` }] });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === "read_doc") {
|
||||
const route = args?.route || "";
|
||||
if (!route) {
|
||||
sendResponse(id, { content: [{ type: "text", text: "Error: Route is required." }] });
|
||||
return;
|
||||
}
|
||||
// Phase 1.A: CWE-22 fix (S-3, T-S1). Reject absolute paths outright and
|
||||
// require relative paths to resolve inside the project root (cwd) via safePath().
|
||||
// Preserves the pre-fix semantic where route was resolved against process.cwd().
|
||||
if (path.isAbsolute(route)) {
|
||||
sendResponse(id, { content: [{ type: "text", text: `Error: Absolute paths are not allowed.` }] });
|
||||
return;
|
||||
}
|
||||
let resolvedFilePath: string;
|
||||
try {
|
||||
resolvedFilePath = safePath(process.cwd(), asUserPath(route));
|
||||
} catch (_e: any) {
|
||||
sendResponse(id, { content: [{ type: "text", text: `Error: Path "${route}" escapes project root.` }] });
|
||||
return;
|
||||
}
|
||||
if (!fs.existsSync(resolvedFilePath)) {
|
||||
sendResponse(id, { content: [{ type: "text", text: `Error: File not found at path "${route}".` }] });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const rawContent = fs.readFileSync(resolvedFilePath, 'utf8');
|
||||
sendResponse(id, { content: [{ type: "text", text: rawContent }] });
|
||||
} catch (err: any) {
|
||||
sendResponse(id, { content: [{ type: "text", text: `Error reading file: ${err.message}` }] });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === "validate_docs") {
|
||||
const errors = validateLinks(docsDir);
|
||||
if (errors.length === 0) {
|
||||
sendResponse(id, { content: [{ type: "text", text: "Documentation links validated successfully! No broken links found." }] });
|
||||
} else {
|
||||
const errorReport = errors.map(e => `[${e.file}:${e.line}] -> ${e.link} (${e.error})`).join('\n');
|
||||
sendResponse(id, { content: [{ type: "text", text: `Validation errors found:\n${errorReport}` }] });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === "get_llms_context") {
|
||||
// Trigger build to ensure context is up to date
|
||||
try {
|
||||
console.error("Building site context...");
|
||||
await buildSite('docmd.config.js', { quiet: true, isDev: false });
|
||||
} catch (err: any) {
|
||||
console.error(`Build warning: ${err.message}`);
|
||||
}
|
||||
|
||||
const llmsFullFile = path.resolve(process.cwd(), config.out || 'site', 'llms-full.txt');
|
||||
if (fs.existsSync(llmsFullFile)) {
|
||||
try {
|
||||
const fullContext = fs.readFileSync(llmsFullFile, 'utf8');
|
||||
sendResponse(id, { content: [{ type: "text", text: fullContext }] });
|
||||
} catch (err: any) {
|
||||
sendResponse(id, { content: [{ type: "text", text: `Error reading context file: ${err.message}` }] });
|
||||
}
|
||||
} else {
|
||||
sendResponse(id, { content: [{ type: "text", text: "Error: llms-full.txt context file has not been generated. Please make sure the build finishes successfully." }] });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
sendError(id, -32601, `Method not found: ${name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
sendError(id, -32601, `Method not found: ${method}`);
|
||||
} catch (err: any) {
|
||||
sendError(null, -32700, `Parse error: ${err.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* @package @docmd/core (and ecosystem)
|
||||
* @website https://docmd.io
|
||||
* @repository https://github.com/docmd-io/docmd
|
||||
* @license MIT
|
||||
* @copyright Copyright (c) 2025-present docmd.io
|
||||
*
|
||||
* [docmd-source] - Please do not remove this header.
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { fsUtils as fs } from '@docmd/utils';
|
||||
import path from 'path';
|
||||
import nativeFs from 'fs';
|
||||
import { TUI } from '@docmd/api';
|
||||
|
||||
function serializeConfig(obj: any) {
|
||||
const json = JSON.stringify(obj, null, 2);
|
||||
const cleanJs = json.replace(/"([^"]+)":/g, '$1:');
|
||||
return `export default ${cleanJs};\n`;
|
||||
}
|
||||
|
||||
export async function migrateProject(options: { docusaurus?: boolean; mkdocs?: boolean; vitepress?: boolean; starlight?: boolean; upgrade?: boolean; dryRun?: boolean }) {
|
||||
const CWD = process.cwd();
|
||||
const dryRun = options.dryRun === true;
|
||||
|
||||
const moveFilesToBackup = async (backupDir: string) => {
|
||||
const backupName = path.basename(backupDir);
|
||||
const files = await nativeFs.promises.readdir(CWD);
|
||||
for (const file of files) {
|
||||
// N-10: keep lockfiles and package manifests in place — moving them
|
||||
// into the backup dir means `npm install` and `pnpm install`
|
||||
// would re-resolve from scratch on a recovery. Lockfiles and
|
||||
// package.json are part of the user's repo state, not migration
|
||||
// input.
|
||||
if (file === 'node_modules' || file === '.git' || file === backupName || file === 'docmd.config.js') continue;
|
||||
if (file === 'package.json' || file === 'package-lock.json' || file === 'yarn.lock' || file === 'pnpm-lock.yaml' || file === 'bun.lockb' || file === 'bun.lock') continue;
|
||||
const oldPath = path.resolve(CWD, file);
|
||||
const newPath = path.resolve(backupDir, file);
|
||||
await nativeFs.promises.rename(oldPath, newPath);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the list of files that `moveFilesToBackup` would move. Used
|
||||
* by `--dry-run` to show the user what would change without writing.
|
||||
* Returns filenames (relative to CWD).
|
||||
*/
|
||||
const planBackupMoves = async (backupName: string): Promise<string[]> => {
|
||||
const files = await nativeFs.promises.readdir(CWD);
|
||||
return files.filter((f) =>
|
||||
f !== 'node_modules' &&
|
||||
f !== '.git' &&
|
||||
f !== backupName &&
|
||||
f !== 'docmd.config.js' &&
|
||||
f !== 'package.json' &&
|
||||
f !== 'package-lock.json' &&
|
||||
f !== 'yarn.lock' &&
|
||||
f !== 'pnpm-lock.yaml' &&
|
||||
f !== 'bun.lockb' &&
|
||||
f !== 'bun.lock'
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Pretty-print a dry-run plan for a source-migration path and exit 0.
|
||||
*/
|
||||
const printAndExitDryRun = async (sourceName: string, backupName: string, docmdConfig: object): Promise<void> => {
|
||||
const moves = await planBackupMoves(backupName);
|
||||
TUI.section(`Dry run: ${sourceName} migration`);
|
||||
TUI.item('Would move', `${moves.length} file${moves.length === 1 ? '' : 's'} → ${backupName}/`);
|
||||
if (moves.length > 0 && moves.length <= 12) {
|
||||
for (const f of moves) TUI.item(' ', f);
|
||||
} else if (moves.length > 12) {
|
||||
for (const f of moves.slice(0, 10)) TUI.item(' ', f);
|
||||
TUI.item(' ', `… and ${moves.length - 10} more`);
|
||||
}
|
||||
TUI.item('Would write', 'docmd.config.js');
|
||||
TUI.item('Config', JSON.stringify(docmdConfig));
|
||||
TUI.footer();
|
||||
TUI.info('No changes made. Re-run without --dry-run to apply.');
|
||||
};
|
||||
|
||||
/**
|
||||
* N-9: best-effort MkDocs `nav:` parser. MkDocs nav is a YAML
|
||||
* nested list; we use a tiny line-by-line YAML-aware scanner
|
||||
* (no `yaml` dependency) that handles the common shape:
|
||||
*
|
||||
* nav:
|
||||
* - Home: index.md
|
||||
* - Guide:
|
||||
* - Getting Started: start.md
|
||||
* - Reference: ref.md
|
||||
*
|
||||
* Sections without an explicit file (`- Guide:` with no value) are
|
||||
* kept as parents; their children are nested under `children`.
|
||||
* Anything fancier (external links, multi-line strings, anchors)
|
||||
* is left to the runtime auto-router.
|
||||
*/
|
||||
const parseMkDocsNav = (rawYaml: string): any[] | null => {
|
||||
const lines = rawYaml.split('\n');
|
||||
const navIdx = lines.findIndex((l) => /^nav\s*:\s*$/.test(l));
|
||||
if (navIdx === -1) return null;
|
||||
|
||||
const root: any[] = [];
|
||||
const stack: { indent: number; items: any[] }[] = [{ indent: -1, items: root }];
|
||||
for (let i = navIdx + 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
// `- Title: file.md` (most common) OR `- Title:` (section header).
|
||||
// The colon + value are optional. The title capture is non-greedy
|
||||
// so it stops at the colon, and we strip a trailing `:` from the
|
||||
// captured title (which happens when the line is `- Title:` with
|
||||
// no file).
|
||||
const m = line.match(/^(\s*)-\s+(.+?)(?::\s*(.+?))?\s*$/);
|
||||
if (!m) continue;
|
||||
const indent = m[1].length;
|
||||
// Strip a trailing `:` from the title (which happens when the
|
||||
// line is `- Title:` with no file value, the section-header
|
||||
// shape in MkDocs).
|
||||
const title = m[2].trim().replace(/:\s*$/, '');
|
||||
const file = (m[3] || '').trim();
|
||||
const item: any = file
|
||||
? { title, path: file.replace(/\.(md|markdown)$/i, '/') }
|
||||
: { title };
|
||||
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) stack.pop();
|
||||
const top = stack[stack.length - 1];
|
||||
top.items.push(item);
|
||||
if (i + 1 < lines.length) {
|
||||
const next = lines[i + 1];
|
||||
const nm = next.match(/^(\s+)-/);
|
||||
if (nm && nm[1].length > indent) {
|
||||
item.children = [];
|
||||
stack.push({ indent, items: item.children });
|
||||
}
|
||||
}
|
||||
}
|
||||
return root.length > 0 ? root : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* T-Z14 / T-Z17: walk a freshly-copied docs directory and translate
|
||||
* Docusaurus-specific frontmatter keys to their docmd equivalents.
|
||||
*
|
||||
* `id:` → removed (docmd derives route ids from filenames)
|
||||
* `sidebar_label:`→ `nav_title:` (the docmd-supported nav override)
|
||||
*
|
||||
* The translation is best-effort: a file that fails to parse keeps
|
||||
* its original content. Returns the count of files touched.
|
||||
*/
|
||||
const translateDocusaurusFrontmatter = async (docsDir: string): Promise<number> => {
|
||||
const targets: string[] = [];
|
||||
const walk = async (dir: string): Promise<void> => {
|
||||
const entries = await nativeFs.promises.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
||||
await walk(full);
|
||||
} else if (entry.isFile() && /\.(md|mdx|markdown)$/i.test(entry.name)) {
|
||||
targets.push(full);
|
||||
}
|
||||
}
|
||||
};
|
||||
await walk(docsDir);
|
||||
|
||||
let count = 0;
|
||||
for (const file of targets) {
|
||||
const raw = await nativeFs.promises.readFile(file, 'utf8');
|
||||
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
if (!m) continue;
|
||||
// Line-by-line parse: we only care about top-level `key: value`
|
||||
// lines. Docusaurus lets these be unquoted, single-quoted, or
|
||||
// double-quoted. Nested objects (e.g. `sidebar:` with sub-keys)
|
||||
// are preserved as-is.
|
||||
const lines = m[1].split('\n');
|
||||
let changed = false;
|
||||
const newLines = lines.map((line) => {
|
||||
const stripped = line.replace(/\r$/, '');
|
||||
const idMatch = stripped.match(/^\s*id\s*:\s*(.*?)\s*$/);
|
||||
if (idMatch) { changed = true; return null; }
|
||||
const slMatch = stripped.match(/^(\s*)sidebar_label\s*:\s*(.*?)\s*$/);
|
||||
if (slMatch) {
|
||||
changed = true;
|
||||
const indent = slMatch[1];
|
||||
let val = slMatch[2];
|
||||
// Strip wrapping quotes if present so the rewritten value
|
||||
// matches the original document's quote style as closely as
|
||||
// possible. docmd accepts both forms.
|
||||
const qm = val.match(/^['"](.*)['"]$/);
|
||||
if (qm) val = qm[1];
|
||||
return `${indent}nav_title: ${val}`;
|
||||
}
|
||||
return stripped;
|
||||
}).filter((l) => l !== null) as string[];
|
||||
|
||||
if (!changed) continue;
|
||||
const newFm = newLines.join('\n');
|
||||
const newContent = raw.replace(/^---\r?\n[\s\S]*?\r?\n---/, `---\n${newFm}\n---`);
|
||||
await nativeFs.promises.writeFile(file, newContent);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
if (options.docusaurus) {
|
||||
TUI.section('Docusaurus Migration');
|
||||
const configPath = path.resolve(CWD, 'docusaurus.config.js');
|
||||
const tsConfigPath = path.resolve(CWD, 'docusaurus.config.ts');
|
||||
|
||||
let activeConfigPath = '';
|
||||
if (nativeFs.existsSync(configPath)) activeConfigPath = configPath;
|
||||
else if (nativeFs.existsSync(tsConfigPath)) activeConfigPath = tsConfigPath;
|
||||
else {
|
||||
TUI.error('Missing configuration', 'docusaurus.config.js or docusaurus.config.ts not found.');
|
||||
// Phase 3 PR 3.A (F6): exit 1 so CI pipelines can gate on it.
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const backupDir = path.resolve(CWD, 'docusaurus-backup');
|
||||
|
||||
const rawConfig = await nativeFs.promises.readFile(activeConfigPath, 'utf8');
|
||||
let title = 'Docmd Site';
|
||||
const titleMatch = rawConfig.match(/title:\s*['"]([^'"]+)['"]/);
|
||||
if (titleMatch) title = titleMatch[1];
|
||||
|
||||
// N-22: preserve the original Docusaurus staticDir if set (default
|
||||
// is `static`). Falling back to `dist` only when the user hasn't
|
||||
// overridden it — overwriting a `site/` directory because we
|
||||
// hardcoded `dist` is the most common silent-clobber pattern.
|
||||
let out = 'dist';
|
||||
const staticDirMatch = rawConfig.match(/staticDir\s*:\s*['"]([^'"]+)['"]/);
|
||||
if (staticDirMatch) out = staticDirMatch[1].replace(/^\.\//, '').replace(/\/$/, '') || 'static';
|
||||
|
||||
const docmdConfig = { title, src: 'docs', out, theme: { appearance: 'system' } };
|
||||
// N-3: dry-run must run BEFORE any side effects (ensureDir, rename).
|
||||
if (dryRun) {
|
||||
await printAndExitDryRun('Docusaurus', 'docusaurus-backup', docmdConfig);
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.ensureDir(backupDir);
|
||||
await moveFilesToBackup(backupDir);
|
||||
TUI.step('Created backup directory', 'DONE');
|
||||
|
||||
const backupDocsDir = path.resolve(backupDir, 'docs');
|
||||
const newDocsDir = path.resolve(CWD, 'docs');
|
||||
if (nativeFs.existsSync(backupDocsDir)) {
|
||||
await fs.copy(backupDocsDir, newDocsDir);
|
||||
TUI.step('Migrated documentation content', 'DONE');
|
||||
} else {
|
||||
await fs.ensureDir(newDocsDir);
|
||||
TUI.step('Created new docs directory', 'DONE');
|
||||
}
|
||||
|
||||
// T-Z14 / T-Z17: translate Docusaurus-specific frontmatter keys.
|
||||
// docmd derives route ids from filenames, so Docusaurus `id:` is
|
||||
// dropped. `sidebar_label:` is preserved as `nav_title:` (a
|
||||
// docmd-supported override of the auto-generated nav title).
|
||||
// The translation is best-effort: files that fail to parse keep
|
||||
// their original frontmatter, and a TUI line reports the count.
|
||||
let fmTranslated = 0;
|
||||
try {
|
||||
const translated = await translateDocusaurusFrontmatter(newDocsDir);
|
||||
fmTranslated = translated;
|
||||
} catch (err: any) {
|
||||
TUI.warn(`Frontmatter translation skipped: ${err.message}`);
|
||||
}
|
||||
if (fmTranslated > 0) {
|
||||
TUI.step(`Translated Docusaurus frontmatter in ${fmTranslated} file(s)`, 'DONE');
|
||||
}
|
||||
|
||||
await nativeFs.promises.writeFile(path.resolve(CWD, 'docmd.config.js'), serializeConfig(docmdConfig));
|
||||
TUI.step('Generated docmd.config.js', 'DONE');
|
||||
|
||||
TUI.footer();
|
||||
TUI.success('Docusaurus migration complete.');
|
||||
TUI.info(`Original files moved to: ${TUI.cyan('docusaurus-backup/')}`);
|
||||
TUI.info(`Run ${TUI.cyan('docmd dev')} to preview your site.`);
|
||||
|
||||
} else if (options.mkdocs) {
|
||||
TUI.section('MkDocs Migration');
|
||||
const configPath = path.resolve(CWD, 'mkdocs.yml');
|
||||
|
||||
if (!nativeFs.existsSync(configPath)) {
|
||||
TUI.error('Missing configuration', 'mkdocs.yml not found.');
|
||||
// Phase 3 PR 3.A (F6): exit 1 so CI pipelines can gate on it.
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const backupDir = path.resolve(CWD, 'mkdocs-backup');
|
||||
|
||||
const rawConfig = await nativeFs.promises.readFile(configPath, 'utf8');
|
||||
let title = 'Docmd Site';
|
||||
const titleMatch = rawConfig.match(/^site_name:\s*['"]?([^'"\n]+)['"]?/m);
|
||||
if (titleMatch) title = titleMatch[1].trim();
|
||||
|
||||
// N-22: preserve the original `site_dir` (MkDocs default is `site`).
|
||||
// N-9 (partial): also build a basic nav tree from MkDocs `nav:`.
|
||||
let out = 'site';
|
||||
const siteDirMatch = rawConfig.match(/^site_dir\s*:\s*['"]?([^'"\n]+)['"]?/m);
|
||||
if (siteDirMatch) out = siteDirMatch[1].trim();
|
||||
// N-9: parse a simple top-level nav. This is a best-effort translation;
|
||||
// complex MkDocs nav trees (multi-level, with external links) fall
|
||||
// back to auto-generated nav at runtime.
|
||||
const nav = parseMkDocsNav(rawConfig);
|
||||
|
||||
const docmdConfig: any = { title, src: 'docs', out, theme: { appearance: 'system' } };
|
||||
if (nav && nav.length > 0) docmdConfig.navigation = nav;
|
||||
// N-3: dry-run must run BEFORE any side effects (ensureDir, rename).
|
||||
if (dryRun) {
|
||||
await printAndExitDryRun('MkDocs', 'mkdocs-backup', docmdConfig);
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.ensureDir(backupDir);
|
||||
await moveFilesToBackup(backupDir);
|
||||
TUI.step('Created backup directory', 'DONE');
|
||||
|
||||
const backupDocsDir = path.resolve(backupDir, 'docs');
|
||||
const newDocsDir = path.resolve(CWD, 'docs');
|
||||
if (nativeFs.existsSync(backupDocsDir)) {
|
||||
await fs.copy(backupDocsDir, newDocsDir);
|
||||
TUI.step('Migrated documentation content', 'DONE');
|
||||
} else {
|
||||
await fs.ensureDir(newDocsDir);
|
||||
TUI.step('Created new docs directory', 'DONE');
|
||||
}
|
||||
|
||||
await nativeFs.promises.writeFile(path.resolve(CWD, 'docmd.config.js'), serializeConfig(docmdConfig));
|
||||
TUI.step('Generated docmd.config.js', 'DONE');
|
||||
|
||||
TUI.footer();
|
||||
TUI.success('MkDocs migration complete.');
|
||||
TUI.info(`Original files moved to: ${TUI.cyan('mkdocs-backup/')}`);
|
||||
TUI.info(`Run ${TUI.cyan('docmd dev')} to preview your site.`);
|
||||
|
||||
} else if (options.vitepress) {
|
||||
TUI.section('VitePress Migration');
|
||||
const CWD = process.cwd();
|
||||
|
||||
let configDir = '';
|
||||
let activeConfigPath = '';
|
||||
|
||||
// Check if config is in root or docs
|
||||
for (const dir of ['.vitepress', 'docs/.vitepress']) {
|
||||
for (const ext of ['js', 'ts', 'mjs']) {
|
||||
const p = path.resolve(CWD, `${dir}/config.${ext}`);
|
||||
if (nativeFs.existsSync(p)) {
|
||||
configDir = dir;
|
||||
activeConfigPath = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (activeConfigPath) break;
|
||||
}
|
||||
|
||||
if (!activeConfigPath) {
|
||||
TUI.error('Missing configuration', '.vitepress/config.[js|ts|mjs] not found.');
|
||||
// Phase 3 PR 3.A (F6): exit 1 so CI pipelines can gate on it.
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const backupDir = path.resolve(CWD, 'vitepress-backup');
|
||||
|
||||
const rawConfig = await nativeFs.promises.readFile(activeConfigPath, 'utf8');
|
||||
let title = 'Docmd Site';
|
||||
const titleMatch = rawConfig.match(/title:\s*['"]([^'"]+)['"]/);
|
||||
if (titleMatch) title = titleMatch[1];
|
||||
|
||||
const docmdConfig = { title, src: 'docs', out: 'dist', theme: { appearance: 'system' } };
|
||||
// N-3: dry-run must run BEFORE any side effects.
|
||||
if (dryRun) {
|
||||
await printAndExitDryRun('VitePress', 'vitepress-backup', docmdConfig);
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.ensureDir(backupDir);
|
||||
await moveFilesToBackup(backupDir);
|
||||
TUI.step('Created backup directory', 'DONE');
|
||||
|
||||
const isDocsInRoot = configDir === '.vitepress';
|
||||
const newDocsDir = path.resolve(CWD, 'docs');
|
||||
await fs.ensureDir(newDocsDir);
|
||||
|
||||
if (isDocsInRoot) {
|
||||
const files = await nativeFs.promises.readdir(backupDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.md')) {
|
||||
await fs.copy(path.resolve(backupDir, file), path.resolve(newDocsDir, file));
|
||||
}
|
||||
}
|
||||
TUI.step('Migrated root content to docs/', 'DONE');
|
||||
} else {
|
||||
const backupDocsDir = path.resolve(backupDir, 'docs');
|
||||
if (nativeFs.existsSync(backupDocsDir)) {
|
||||
await fs.copy(backupDocsDir, newDocsDir);
|
||||
await fs.remove(path.resolve(newDocsDir, '.vitepress'));
|
||||
TUI.step('Migrated docs content', 'DONE');
|
||||
}
|
||||
}
|
||||
|
||||
await nativeFs.promises.writeFile(path.resolve(CWD, 'docmd.config.js'), serializeConfig(docmdConfig));
|
||||
TUI.step('Generated docmd.config.js', 'DONE');
|
||||
|
||||
TUI.footer();
|
||||
TUI.success('VitePress migration complete.');
|
||||
TUI.info(`Original files moved to: ${TUI.cyan('vitepress-backup/')}`);
|
||||
TUI.info(`Run ${TUI.cyan('docmd dev')} to preview your site.`);
|
||||
|
||||
} else if (options.starlight) {
|
||||
TUI.section('Starlight Migration');
|
||||
const configPath = path.resolve(CWD, 'astro.config.mjs');
|
||||
const tsConfigPath = path.resolve(CWD, 'astro.config.ts');
|
||||
|
||||
let activeConfigPath = '';
|
||||
if (nativeFs.existsSync(configPath)) activeConfigPath = configPath;
|
||||
else if (nativeFs.existsSync(tsConfigPath)) activeConfigPath = tsConfigPath;
|
||||
else {
|
||||
TUI.error('Missing configuration', 'astro.config.mjs or astro.config.ts not found.');
|
||||
// Phase 3 PR 3.A (F6): exit 1 so CI pipelines can gate on it.
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const backupDir = path.resolve(CWD, 'starlight-backup');
|
||||
|
||||
const rawConfig = await nativeFs.promises.readFile(activeConfigPath, 'utf8');
|
||||
let title = 'Docmd Site';
|
||||
const titleMatch = rawConfig.match(/title:\s*['"]([^'"]+)['"]/);
|
||||
if (titleMatch) title = titleMatch[1];
|
||||
|
||||
const docmdConfig = { title, src: 'docs', out: 'dist', theme: { appearance: 'system' } };
|
||||
// N-3: dry-run must run BEFORE any side effects.
|
||||
if (dryRun) {
|
||||
await printAndExitDryRun('Starlight', 'starlight-backup', docmdConfig);
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.ensureDir(backupDir);
|
||||
await moveFilesToBackup(backupDir);
|
||||
TUI.step('Created backup directory', 'DONE');
|
||||
|
||||
const backupDocsDir = path.resolve(backupDir, 'src/content/docs');
|
||||
const newDocsDir = path.resolve(CWD, 'docs');
|
||||
|
||||
if (nativeFs.existsSync(backupDocsDir)) {
|
||||
await fs.copy(backupDocsDir, newDocsDir);
|
||||
TUI.step('Migrated documentation content', 'DONE');
|
||||
} else {
|
||||
await fs.ensureDir(newDocsDir);
|
||||
TUI.step('Created new docs directory', 'DONE');
|
||||
}
|
||||
|
||||
await nativeFs.promises.writeFile(path.resolve(CWD, 'docmd.config.js'), serializeConfig(docmdConfig));
|
||||
TUI.step('Generated docmd.config.js', 'DONE');
|
||||
|
||||
TUI.footer();
|
||||
TUI.success('Astro Starlight migration complete.');
|
||||
TUI.info(`Original files moved to: ${TUI.cyan('starlight-backup/')}`);
|
||||
TUI.info(`Run ${TUI.cyan('docmd dev')} to preview your site.`);
|
||||
} else if (options.upgrade) {
|
||||
TUI.section('Upgrading Configuration');
|
||||
|
||||
const jsonPath = path.resolve(CWD, 'docmd.config.json');
|
||||
const jsPath = path.resolve(CWD, 'docmd.config.js');
|
||||
const tsPath = path.resolve(CWD, 'docmd.config.ts');
|
||||
|
||||
let activePath = '';
|
||||
let format: 'json' | 'js' | 'ts' = 'json';
|
||||
|
||||
if (nativeFs.existsSync(jsonPath)) {
|
||||
activePath = jsonPath;
|
||||
format = 'json';
|
||||
} else if (nativeFs.existsSync(jsPath)) {
|
||||
activePath = jsPath;
|
||||
format = 'js';
|
||||
} else if (nativeFs.existsSync(tsPath)) {
|
||||
activePath = tsPath;
|
||||
format = 'ts';
|
||||
}
|
||||
|
||||
if (!activePath) {
|
||||
TUI.error('Upgrade Failed', 'No docmd configuration file (docmd.config.json/js/ts) found in current directory.');
|
||||
// Phase 3 PR 3.A (F6): exit 1 so CI pipelines can gate on it.
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
TUI.step(`Found configuration: ${TUI.cyan(path.basename(activePath))}`, 'WAIT');
|
||||
|
||||
try {
|
||||
const raw = await nativeFs.promises.readFile(activePath, 'utf8');
|
||||
let configObj: any = {};
|
||||
let isUpgraded = false;
|
||||
|
||||
if (format === 'json') {
|
||||
configObj = JSON.parse(raw);
|
||||
} else {
|
||||
// Dynamic import to read current exported config values
|
||||
const module = await import(`file://${activePath}`);
|
||||
configObj = module.default || {};
|
||||
}
|
||||
|
||||
// 1. Upgrade legacy top-level 'projects' to 'workspace.projects'
|
||||
if (configObj.projects && Array.isArray(configObj.projects) && !configObj.workspace) {
|
||||
configObj.workspace = {
|
||||
projects: configObj.projects,
|
||||
switcher: {
|
||||
enabled: true,
|
||||
position: 'sidebar-top'
|
||||
}
|
||||
};
|
||||
delete configObj.projects;
|
||||
isUpgraded = true;
|
||||
TUI.step('Upgraded legacy top-level "projects" to "workspace" schema.', 'DONE');
|
||||
}
|
||||
|
||||
// 2. Upgrade legacy 'siteTitle' to 'title'
|
||||
if (configObj.siteTitle && !configObj.title) {
|
||||
configObj.title = configObj.siteTitle;
|
||||
delete configObj.siteTitle;
|
||||
isUpgraded = true;
|
||||
TUI.step('Upgraded "siteTitle" to "title".', 'DONE');
|
||||
}
|
||||
|
||||
// 3. Upgrade legacy 'siteUrl' / 'baseUrl' to 'url'
|
||||
if (configObj.siteUrl && !configObj.url) {
|
||||
configObj.url = configObj.siteUrl;
|
||||
delete configObj.siteUrl;
|
||||
isUpgraded = true;
|
||||
TUI.step('Upgraded "siteUrl" to "url".', 'DONE');
|
||||
}
|
||||
if (configObj.baseUrl && !configObj.url) {
|
||||
configObj.url = configObj.baseUrl;
|
||||
delete configObj.baseUrl;
|
||||
isUpgraded = true;
|
||||
TUI.step('Upgraded "baseUrl" to "url".', 'DONE');
|
||||
}
|
||||
|
||||
// 4. Upgrade legacy 'srcDir' to 'src'
|
||||
if (configObj.srcDir && !configObj.src) {
|
||||
configObj.src = configObj.srcDir;
|
||||
delete configObj.srcDir;
|
||||
isUpgraded = true;
|
||||
TUI.step('Upgraded "srcDir" to "src".', 'DONE');
|
||||
}
|
||||
|
||||
// 5. Upgrade legacy 'outputDir' to 'out'
|
||||
if (configObj.outputDir && !configObj.out) {
|
||||
configObj.out = configObj.outputDir;
|
||||
delete configObj.outputDir;
|
||||
isUpgraded = true;
|
||||
TUI.step('Upgraded "outputDir" to "out".', 'DONE');
|
||||
}
|
||||
|
||||
// 6. Upgrade legacy 'defaultLocale' to 'i18n.default'
|
||||
if (configObj.defaultLocale) {
|
||||
configObj.i18n = configObj.i18n || {};
|
||||
configObj.i18n.default = configObj.defaultLocale;
|
||||
delete configObj.defaultLocale;
|
||||
isUpgraded = true;
|
||||
TUI.step('Upgraded "defaultLocale" to "i18n.default".', 'DONE');
|
||||
}
|
||||
|
||||
// N-4: extend the legacy-key map with the remaining common keys.
|
||||
// The previous upgrade covered siteTitle/siteUrl/baseUrl/srcDir/
|
||||
// outputDir/defaultLocale/projects but left several documented
|
||||
// legacy keys untouched. These are the keys the build-time
|
||||
// loader used in 0.7.x and earlier.
|
||||
|
||||
// 7. 'source' (older alias for 'src')
|
||||
if (configObj.source && !configObj.src) {
|
||||
configObj.src = configObj.source;
|
||||
delete configObj.source;
|
||||
isUpgraded = true;
|
||||
TUI.step('Upgraded "source" to "src".', 'DONE');
|
||||
}
|
||||
|
||||
// 8. 'outDir' (older alias for 'out')
|
||||
if (configObj.outDir && !configObj.out) {
|
||||
configObj.out = configObj.outDir;
|
||||
delete configObj.outDir;
|
||||
isUpgraded = true;
|
||||
TUI.step('Upgraded "outDir" to "out".', 'DONE');
|
||||
}
|
||||
|
||||
// 9. 'nav' (legacy sidebar structure) → 'navigation'
|
||||
if (configObj.nav && !configObj.navigation) {
|
||||
configObj.navigation = configObj.nav;
|
||||
delete configObj.nav;
|
||||
isUpgraded = true;
|
||||
TUI.step('Upgraded "nav" to "navigation".', 'DONE');
|
||||
}
|
||||
|
||||
// 10. Top-level 'search' boolean → 'plugins.search'
|
||||
if (typeof configObj.search === 'boolean' && !configObj.plugins?.search) {
|
||||
configObj.plugins = configObj.plugins || {};
|
||||
configObj.plugins.search = configObj.search === true ? {} : { enabled: false };
|
||||
delete configObj.search;
|
||||
isUpgraded = true;
|
||||
TUI.step('Upgraded top-level "search" to "plugins.search".', 'DONE');
|
||||
}
|
||||
|
||||
// 11. Top-level 'sidebar' → 'layout.sidebar'
|
||||
if (configObj.sidebar && !configObj.layout?.sidebar) {
|
||||
configObj.layout = configObj.layout || {};
|
||||
configObj.layout.sidebar = configObj.sidebar;
|
||||
delete configObj.sidebar;
|
||||
isUpgraded = true;
|
||||
TUI.step('Upgraded top-level "sidebar" to "layout.sidebar".', 'DONE');
|
||||
}
|
||||
|
||||
// 12. 'theme.enableModeToggle' → 'optionsMenu.components.themeSwitch'
|
||||
if (configObj.theme?.enableModeToggle !== undefined) {
|
||||
configObj.optionsMenu = configObj.optionsMenu || {};
|
||||
configObj.optionsMenu.components = configObj.optionsMenu.components || {};
|
||||
configObj.optionsMenu.components.themeSwitch = configObj.theme.enableModeToggle !== false;
|
||||
delete configObj.theme.enableModeToggle;
|
||||
isUpgraded = true;
|
||||
TUI.step('Upgraded "theme.enableModeToggle" to "optionsMenu.components.themeSwitch".', 'DONE');
|
||||
}
|
||||
|
||||
// 13. 'theme.positionMode' → 'optionsMenu.position'
|
||||
if (configObj.theme?.positionMode && !configObj.optionsMenu?.position) {
|
||||
configObj.optionsMenu = configObj.optionsMenu || {};
|
||||
configObj.optionsMenu.position = configObj.theme.positionMode === 'bottom' ? 'sidebar-bottom' : 'sidebar-top';
|
||||
delete configObj.theme.positionMode;
|
||||
isUpgraded = true;
|
||||
TUI.step('Upgraded "theme.positionMode" to "optionsMenu.position".', 'DONE');
|
||||
}
|
||||
|
||||
// 14. 'theme.defaultMode' → 'theme.appearance'
|
||||
if (configObj.theme?.defaultMode && !configObj.theme?.appearance) {
|
||||
configObj.theme.appearance = configObj.theme.defaultMode;
|
||||
delete configObj.theme.defaultMode;
|
||||
isUpgraded = true;
|
||||
TUI.step('Upgraded "theme.defaultMode" to "theme.appearance".', 'DONE');
|
||||
}
|
||||
|
||||
if (!isUpgraded) {
|
||||
TUI.footer();
|
||||
TUI.success('Configuration is already up to date with the latest schema.');
|
||||
return;
|
||||
}
|
||||
|
||||
// N-3: dry-run prints the upgraded config and exits 0 without writing.
|
||||
if (dryRun) {
|
||||
TUI.section('Dry run: config upgrade');
|
||||
TUI.item('Config path', activePath);
|
||||
TUI.item('Upgraded config', JSON.stringify(configObj, null, 2));
|
||||
TUI.footer();
|
||||
TUI.info('No changes made. Re-run without --dry-run to apply.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Write upgraded config back
|
||||
if (format === 'json') {
|
||||
await nativeFs.promises.writeFile(activePath, JSON.stringify(configObj, null, 2) + '\n');
|
||||
} else {
|
||||
let content = serializeConfig(configObj);
|
||||
if (format === 'ts') {
|
||||
content = `import { UserConfig } from '@docmd/api';\n\nconst config: UserConfig = ${JSON.stringify(configObj, null, 2).replace(/"([^"]+)":/g, '$1:')};\n\nexport default config;\n`;
|
||||
}
|
||||
await nativeFs.promises.writeFile(activePath, content);
|
||||
}
|
||||
|
||||
TUI.footer();
|
||||
TUI.success(`Successfully upgraded config file to modern schema.`);
|
||||
} catch (error: any) {
|
||||
TUI.error('Upgrade Error', `Failed to parse or write config file: ${error.message}`);
|
||||
// Phase 3 PR 3.A (F6): exit 1 so CI pipelines can gate on it.
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* @package @docmd/core (and ecosystem)
|
||||
* @website https://docmd.io
|
||||
* @repository https://github.com/docmd-io/docmd
|
||||
* @license MIT
|
||||
* @copyright Copyright (c) 2025-present docmd.io
|
||||
*
|
||||
* [docmd-source] - Please do not remove this header.
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { TUI } from '@docmd/api';
|
||||
|
||||
// M-11: how long to wait after SIGTERM before escalating to SIGKILL.
|
||||
// Docmd's dev server installs a graceful-shutdown handler on SIGTERM
|
||||
// (closes watchers, the http server, the WebSocket server, and the
|
||||
// worker pool). `docmd stop` waits this long for the process to exit
|
||||
// on its own before sending the uncatchable SIGKILL.
|
||||
const SIGTERM_GRACE_MS = 5000;
|
||||
|
||||
/**
|
||||
* Poll `pid` until it has exited or `timeoutMs` has elapsed.
|
||||
* Returns `true` if the process exited within the window, `false`
|
||||
* if it was still alive at the timeout (caller should escalate to
|
||||
* SIGKILL). Polls every 100ms to keep the perceived latency low.
|
||||
*/
|
||||
export async function waitForExit(pid: number, timeoutMs: number): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
// signal 0 = existence check; throws ESRCH if the pid is gone
|
||||
process.kill(pid, 0);
|
||||
} catch (err: any) {
|
||||
if (err && err.code === 'ESRCH') return true;
|
||||
// EPERM means the process exists but we can't signal it — treat
|
||||
// as still alive and let the outer catch handle escalation.
|
||||
return false;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* find and kill running docmd processes
|
||||
* If port is provided, only kill the process listening on that port.
|
||||
* If force is true, also kill serve processes on common docmd ports.
|
||||
*/
|
||||
export async function stopServer(port: any, force: boolean = false) {
|
||||
// Common ports used by docmd dev server
|
||||
const commonPorts = [3000, 3001, 8080, 8081];
|
||||
|
||||
if (port) {
|
||||
TUI.section(`Stopping port ${port}`);
|
||||
try {
|
||||
// Try lsof first
|
||||
let pid = '';
|
||||
try {
|
||||
pid = execSync(`lsof -t -i:${port}`).toString().trim();
|
||||
} catch {
|
||||
// Fallback to fuser if lsof fails
|
||||
try {
|
||||
pid = execSync(`fuser -k ${port}/tcp 2>/dev/null`).toString().trim();
|
||||
} catch {
|
||||
// Fallback to netstat
|
||||
const netstat = execSync(`netstat -anp tcp 2>/dev/null | grep LISTEN | grep ${port}`).toString();
|
||||
const match = netstat.match(/(\d+)\/\w+/);
|
||||
if (match) pid = match[1];
|
||||
}
|
||||
}
|
||||
if (pid) {
|
||||
TUI.step(`Found process ${pid} on port ${port}`, 'WAIT');
|
||||
const pidNum = Number(pid);
|
||||
// M-11: send SIGTERM first, wait up to SIGTERM_GRACE_MS for the
|
||||
// process to exit on its own (the dev server installs a
|
||||
// graceful shutdown handler), and only then escalate to SIGKILL.
|
||||
try {
|
||||
process.kill(pidNum, 'SIGTERM');
|
||||
} catch (err: any) {
|
||||
TUI.step(`Failed to send SIGTERM to PID ${pid}: ${err.message}`, 'FAIL', TUI.red);
|
||||
}
|
||||
if (await waitForExit(pidNum, SIGTERM_GRACE_MS)) {
|
||||
TUI.footer();
|
||||
TUI.success(`Docmd instance on port ${port} stopped gracefully.`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
process.kill(pidNum, 'SIGKILL');
|
||||
} catch (err: any) {
|
||||
TUI.step(`Failed to send SIGKILL to PID ${pid}: ${err.message}`, 'FAIL', TUI.red);
|
||||
}
|
||||
TUI.footer();
|
||||
TUI.success(`Docmd instance on port ${port} has been stopped.`);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// No process found
|
||||
}
|
||||
TUI.step(`No instance found on port ${port}`, 'SKIP');
|
||||
TUI.footer();
|
||||
return;
|
||||
}
|
||||
|
||||
TUI.section('Stopping all instances');
|
||||
|
||||
try {
|
||||
// Get all processes with PIDs and full command lines
|
||||
// We filter for docmd but exclude the grep itself and the current process
|
||||
const currentPid = process.pid;
|
||||
|
||||
// Use ps to list processes. -ax to see all, -o pid,command for details.
|
||||
const output = execSync('ps -ax -o pid,command').toString();
|
||||
const lines = output.split('\n');
|
||||
|
||||
const targets = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
const [pidStr, ...cmdParts] = trimmed.split(/\s+/);
|
||||
const pid = parseInt(pidStr, 10);
|
||||
const command = cmdParts.join(' ');
|
||||
|
||||
// Check if it's a docmd process (dev or live) but not the current one
|
||||
// We look for 'docmd dev', 'docmd live', or direct bin/docmd.js execution
|
||||
const isDocmd = command.includes('docmd dev') ||
|
||||
command.includes('docmd live') ||
|
||||
command.includes('bin/docmd.js');
|
||||
|
||||
// With --force, also detect 'serve' processes on common docmd ports
|
||||
let isServe = false;
|
||||
if (force) {
|
||||
isServe = (command.includes('serve ') || command.includes('serve -p')) &&
|
||||
commonPorts.some(p => command.includes(`-p ${p}`) || command.includes(`--port ${p}`));
|
||||
}
|
||||
|
||||
const isNotCurrent = pid !== currentPid && command.indexOf('stop') === -1;
|
||||
|
||||
if ((isDocmd || isServe) && isNotCurrent) {
|
||||
targets.push({ pid, command, type: isServe ? 'serve' : 'docmd' });
|
||||
}
|
||||
}
|
||||
|
||||
if (targets.length === 0) {
|
||||
TUI.step('No running docmd instances found', 'SKIP');
|
||||
TUI.footer();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const target of targets) {
|
||||
try {
|
||||
TUI.step(`Stopping ${target.type} PID ${target.pid} (SIGTERM)`, 'WAIT');
|
||||
process.kill(target.pid, 'SIGTERM');
|
||||
} catch {
|
||||
// If SIGTERM fails, skip the graceful wait
|
||||
}
|
||||
// M-11: give the graceful-shutdown handler time to run before
|
||||
// escalating to SIGKILL.
|
||||
if (!(await waitForExit(target.pid, SIGTERM_GRACE_MS))) {
|
||||
try {
|
||||
process.kill(target.pid, 'SIGKILL');
|
||||
TUI.step(`Killed ${target.type} PID ${target.pid} (SIGKILL after grace)`, 'DONE');
|
||||
} catch (err2: any) {
|
||||
TUI.step(`Failed to kill PID ${target.pid}: ${err2.message}`, 'FAIL', TUI.red);
|
||||
}
|
||||
} else {
|
||||
TUI.step(`Stopped ${target.type} PID ${target.pid} gracefully`, 'DONE');
|
||||
}
|
||||
}
|
||||
|
||||
TUI.footer();
|
||||
TUI.success('All docmd instances have been stopped.');
|
||||
|
||||
} catch (error: any) {
|
||||
TUI.error('Error during stop', error.message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* @package @docmd/core (and ecosystem)
|
||||
* @website https://docmd.io
|
||||
* @repository https://github.com/docmd-io/docmd
|
||||
* @license MIT
|
||||
* @copyright Copyright (c) 2025-present docmd.io
|
||||
*
|
||||
* [docmd-source] - Please do not remove this header.
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { TUI } from '@docmd/api';
|
||||
import { loadConfig } from '../utils/config-loader.js';
|
||||
import { validateLinks } from './mcp.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
export async function validateProject(options: { json?: boolean } = {}) {
|
||||
let config: any;
|
||||
try {
|
||||
config = await loadConfig('docmd.config.js', { quiet: true });
|
||||
} catch {
|
||||
config = { src: 'docs' };
|
||||
}
|
||||
|
||||
const docsDir = path.resolve(process.cwd(), config.src || 'docs');
|
||||
|
||||
if (!fs.existsSync(docsDir)) {
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ error: `Docs directory not found at ${docsDir}`, errors: [] }));
|
||||
} else {
|
||||
TUI.error('Validation Error', `Docs directory not found: ${docsDir}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const errors = validateLinks(docsDir);
|
||||
|
||||
if (options.json) {
|
||||
// Phase 3 PR 3.A (M-12): even when JSON output is requested, the
|
||||
// process MUST exit 1 if any errors were found. CI pipelines
|
||||
// (`docmd validate --json | jq .errors | if [ $(. | length) -gt 0 ]; then exit 1`)
|
||||
// rely on the exit code, not the JSON payload, to gate merges.
|
||||
console.log(JSON.stringify({ errors }));
|
||||
if (errors.length > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
TUI.section('Documentation Validation');
|
||||
if (errors.length === 0) {
|
||||
TUI.success('All internal links and references are valid!');
|
||||
} else {
|
||||
TUI.error('Validation Failed', `Found ${errors.length} broken links:`);
|
||||
errors.forEach(e => {
|
||||
TUI.item(`[${e.file}:${e.line}]`, `${e.link} -> ${e.error}`);
|
||||
});
|
||||
TUI.footer();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* @package @docmd/core (and ecosystem)
|
||||
* @website https://docmd.io
|
||||
* @repository https://github.com/docmd-io/docmd
|
||||
* @license MIT
|
||||
* @copyright Copyright (c) 2025-present docmd.io
|
||||
*
|
||||
* [docmd-source] - Please do not remove this header.
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { fsUtils as fs } from '@docmd/utils';
|
||||
import esbuild from 'esbuild';
|
||||
import { createRequire } from 'module';
|
||||
import nativeFs from 'fs';
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
import * as themes from '@docmd/themes';
|
||||
import * as ui from '@docmd/ui';
|
||||
|
||||
const pkgUrl = new URL('../../package.json', import.meta.url);
|
||||
const pkg = JSON.parse(nativeFs.readFileSync(pkgUrl, 'utf8'));
|
||||
|
||||
const COPYRIGHT_BANNER = `/*!
|
||||
* docmd (v${pkg.version})
|
||||
* Copyright (c) 2025-present docmd.io
|
||||
* License: MIT
|
||||
*/`;
|
||||
|
||||
export async function findFilesRecursive(dir: string, extensions: string[]): Promise<string[]> {
|
||||
let files: string[] = [];
|
||||
if (!await fs.exists(dir)) return [];
|
||||
const items = await nativeFs.promises.readdir(dir, { withFileTypes: true });
|
||||
for (const item of items) {
|
||||
// Explicitly ignore system files, git, and node_modules to prevent duplicate ID crashes
|
||||
if (item.name === 'node_modules' || item.name.startsWith('.') || item.name === 'site') continue;
|
||||
|
||||
const fullPath = path.join(dir, item.name);
|
||||
if (item.isDirectory()) {
|
||||
files = files.concat(await findFilesRecursive(fullPath, extensions));
|
||||
} else if (item.isFile()) {
|
||||
if (!extensions || extensions.includes(path.extname(item.name))) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
export async function prepareAssets(config: any, outputDir: string, options: any = {}) {
|
||||
const CWD = process.cwd();
|
||||
|
||||
// 1. Core UI Assets
|
||||
const uiAssets = ui.getAssetsDir();
|
||||
if (await fs.exists(uiAssets)) await fs.copy(uiAssets, path.join(outputDir, 'assets'));
|
||||
|
||||
// 2. Theme Assets
|
||||
const themesDir = themes.getThemesDir();
|
||||
if (await fs.exists(themesDir)) await fs.copy(themesDir, path.join(outputDir, 'assets/css'));
|
||||
|
||||
// 3. User Assets (Root)
|
||||
const userAssets = path.resolve(CWD, 'assets');
|
||||
if (await fs.exists(userAssets)) await fs.copy(userAssets, path.join(outputDir, 'assets'));
|
||||
|
||||
// 3.5. User Assets (Docs Dir)
|
||||
if (config.src) {
|
||||
const srcAssets = path.resolve(CWD, config.src, 'assets');
|
||||
if (await fs.exists(srcAssets)) await fs.copy(srcAssets, path.join(outputDir, 'assets'));
|
||||
}
|
||||
|
||||
// 4. Minification (Production only)
|
||||
if (config.minify !== false && !options.isDev) {
|
||||
await minifyDir(path.join(outputDir, 'assets'));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Template Assets (new in 0.8.7)
|
||||
// Templates ship their own CSS/JS bundles. We copy them into
|
||||
// `assets/template/<basename>` so they survive minification and
|
||||
// can be referenced with a stable URL.
|
||||
// ---------------------------------------------------------------------------
|
||||
export async function prepareTemplateAssets(config: any, outputDir: string) {
|
||||
// The hooks object is exported by @docmd/api. We import lazily to avoid
|
||||
// a hard build-time cycle between @docmd/core ↔ @docmd/api.
|
||||
const { hooks } = await import('@docmd/api');
|
||||
if (!hooks || !Array.isArray(hooks.templateAssets) || hooks.templateAssets.length === 0) return;
|
||||
|
||||
const templateDir = path.join(outputDir, 'assets', 'template');
|
||||
await fs.ensureDir(templateDir);
|
||||
|
||||
for (const asset of hooks.templateAssets) {
|
||||
if (!asset || !asset.path || !asset.type) continue;
|
||||
if (asset.type !== 'css' && asset.type !== 'js') continue;
|
||||
if (!await fs.exists(asset.path)) {
|
||||
// Don't crash the build — the resolver already warned at render time.
|
||||
continue;
|
||||
}
|
||||
const dest = path.join(templateDir, path.basename(asset.path));
|
||||
await fs.copy(asset.path, dest);
|
||||
}
|
||||
}
|
||||
|
||||
async function minifyDir(dir: string) {
|
||||
const assets = await findFilesRecursive(dir, ['.css', '.js']);
|
||||
for (const file of assets) {
|
||||
if (file.endsWith('.min.js') || file.endsWith('.min.css')) continue;
|
||||
try {
|
||||
const ext = path.extname(file);
|
||||
const content = await nativeFs.promises.readFile(file, 'utf8');
|
||||
const result = await esbuild.transform(content, {
|
||||
loader: ext.slice(1) as any,
|
||||
minify: true,
|
||||
legalComments: 'none'
|
||||
});
|
||||
await nativeFs.promises.writeFile(file, COPYRIGHT_BANNER + '\n' + result.code);
|
||||
} catch {
|
||||
// Ignore errors for non-standard files or mixed content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate HTML Tag Helper
|
||||
export function generateAssetTag(pathOrUrl: string, type: string, attributes: any = {}) {
|
||||
const attrs = Object.entries(attributes).map(([k, v]) => v === true ? k : `${k}="${v}"`).join(' ');
|
||||
if (type === 'css') return `<link rel="stylesheet" href="${pathOrUrl}" ${attrs}>`;
|
||||
if (type === 'js') return `<script src="${pathOrUrl}" ${attrs}></script>`;
|
||||
return '';
|
||||
}
|
||||
@@ -0,0 +1,837 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* @package @docmd/core (and ecosystem)
|
||||
* @website https://docmd.io
|
||||
* @repository https://github.com/docmd-io/docmd
|
||||
* @license MIT
|
||||
* @copyright Copyright (c) 2025-present docmd.io
|
||||
*
|
||||
* [docmd-source] - Please do not remove this header.
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { fsUtils as fs } from '@docmd/utils';
|
||||
import { createRequire } from 'module';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
/**
|
||||
* Convert every segment of a relative file path to a URL-safe slug,
|
||||
* mirroring the slugifySegment logic in auto-router.ts.
|
||||
* This ensures the output path written to disk matches the URL that
|
||||
* buildAutoNav generates for the same file.
|
||||
*
|
||||
* Example: "SA/folder with space/page with space"
|
||||
* → "SA/folder-with-space/page-with-space"
|
||||
*/
|
||||
function slugifyOutputPath(p: string): string {
|
||||
return p
|
||||
.split('/')
|
||||
.map(seg =>
|
||||
seg
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-zA-Z0-9\-_.~]/g, '-')
|
||||
.replace(/-{2,}/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
|| seg
|
||||
)
|
||||
.join('/');
|
||||
}
|
||||
import { generateAssetTag, findFilesRecursive } from './assets.js';
|
||||
import { generateHreflangTags } from './i18n.js';
|
||||
import nativeFs from 'fs';
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
import * as parser from '@docmd/parser';
|
||||
import { TUI } from '@docmd/tui';
|
||||
import { findPageNeighbors, findBreadcrumbs, normalizeNavPaths, createUrlContext, buildContextualUrl, computePageUrls, buildAbsoluteUrl, sanitizeUrl } from '@docmd/parser';
|
||||
import * as ui from '@docmd/ui';
|
||||
|
||||
|
||||
|
||||
/* ── Core package version (for <meta name="generator">) ──────── */
|
||||
const _pkgUrl = new URL('../../package.json', import.meta.url);
|
||||
const _pkg = JSON.parse(nativeFs.readFileSync(_pkgUrl, 'utf8')) as { version: string };
|
||||
const CORE_VERSION: string = _pkg.version;
|
||||
|
||||
/* ── Constants ────────────────────────────────────────────────── */
|
||||
|
||||
/** Number of pages to process concurrently in each batch. */
|
||||
const BATCH_SIZE = 64;
|
||||
|
||||
/** Number of pages to write to disk concurrently. */
|
||||
const WRITE_BATCH_SIZE = 128;
|
||||
|
||||
/* ── Git Root Detection (for edit links) ─────────────────────── */
|
||||
|
||||
/** Cached git root path (null = not yet detected, '' = not a git repo). */
|
||||
let _cachedGitRoot: string | null = null;
|
||||
|
||||
/**
|
||||
* Detect the git repository root for the current working directory.
|
||||
* Returns the absolute path to the git root, or null if not in a git repo.
|
||||
* Result is cached per build (cache resets when cwd changes).
|
||||
*/
|
||||
function getGitRoot(): string | null {
|
||||
if (_cachedGitRoot !== null) {
|
||||
return _cachedGitRoot || null;
|
||||
}
|
||||
try {
|
||||
_cachedGitRoot = execSync('git rev-parse --show-toplevel', {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'pipe',
|
||||
encoding: 'utf8'
|
||||
}).trim();
|
||||
return _cachedGitRoot;
|
||||
} catch {
|
||||
_cachedGitRoot = '';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Types ────────────────────────────────────────────────────── */
|
||||
|
||||
interface RenderPagesOptions {
|
||||
config: any;
|
||||
srcDir: string;
|
||||
fallbackSrcDir: string | null;
|
||||
outputDir: string;
|
||||
hooks: any;
|
||||
buildHash: string;
|
||||
options: any;
|
||||
outputPrefix?: string;
|
||||
/** docmd core version (e.g. "0.8.7"). Used for the <meta name="generator"> tag. */
|
||||
coreVersion?: string;
|
||||
/** Progress callback: (current, total) called after each batch completes. */
|
||||
onProgress?: (current: number, total: number) => void;
|
||||
/** Optional: only render specific files (relative to srcDir). Used for incremental dev rebuilds. */
|
||||
targetFiles?: string[];
|
||||
}
|
||||
|
||||
export async function renderPages({ config, srcDir, fallbackSrcDir, outputDir, hooks, buildHash, options, outputPrefix = '', coreVersion, onProgress, targetFiles }: RenderPagesOptions) {
|
||||
// Reset git root cache (cwd may have changed between workspace builds)
|
||||
_cachedGitRoot = null;
|
||||
|
||||
// Load Translations for the active locale
|
||||
const localeId = config._activeLocale?.id || null;
|
||||
const pluginTranslations = hooks.translations
|
||||
? await hooks.translations.reduce(async (accP: any, fn: any) => ({ ...(await accP), ...(await fn(localeId)) }), {})
|
||||
: {};
|
||||
|
||||
// Merge: system translations → plugin translations → user-provided locale translations
|
||||
const userLocaleTranslations = config._activeLocale?.translations || {};
|
||||
const strings = ui.loadTranslations(localeId, { ...pluginTranslations, ...userLocaleTranslations });
|
||||
const t = ui.createT(strings);
|
||||
|
||||
// Resolve locale-specific navigation.json
|
||||
// Each locale dir has its own navigation.json; fall back to default locale's navigation
|
||||
if (config.i18n && config._activeLocale) {
|
||||
const localeNavPath = path.join(srcDir, 'navigation.json');
|
||||
try {
|
||||
if (nativeFs.existsSync(localeNavPath)) {
|
||||
const rawNav = await nativeFs.promises.readFile(localeNavPath, 'utf8');
|
||||
const parsedNav = JSON.parse(rawNav);
|
||||
normalizeNavPaths(parsedNav);
|
||||
config = { ...config, navigation: parsedNav };
|
||||
} else if (fallbackSrcDir) {
|
||||
// Fall back to default locale's navigation.json
|
||||
const fallbackNavPath = path.join(fallbackSrcDir, 'navigation.json');
|
||||
if (nativeFs.existsSync(fallbackNavPath)) {
|
||||
const rawNav = await nativeFs.promises.readFile(fallbackNavPath, 'utf8');
|
||||
const parsedNav = JSON.parse(rawNav);
|
||||
normalizeNavPaths(parsedNav);
|
||||
config = { ...config, navigation: parsedNav };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
TUI.warn(`Failed to parse locale navigation: ${localeNavPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Pass UI strings to the markdown processor (for locale-aware aria-labels etc.)
|
||||
const configWithStrings = { ...config, _uiStrings: strings };
|
||||
const mdProcessor = parser.createMarkdownProcessor(configWithStrings, (md: any) => hooks.markdownSetup.forEach((hook: any) => hook(md)));
|
||||
|
||||
// Load Layout Templates
|
||||
const templates = {
|
||||
layout: await nativeFs.promises.readFile(ui.getTemplatePath('layout'), 'utf8'),
|
||||
noStyle: await nativeFs.promises.readFile(ui.getTemplatePath('no-style'), 'utf8'),
|
||||
navigation: await nativeFs.promises.readFile(ui.getTemplatePath('navigation'), 'utf8'),
|
||||
'docmd-search': await nativeFs.promises.readFile(ui.getTemplatePath('docmd-search'), 'utf8'),
|
||||
};
|
||||
|
||||
// Load Partials
|
||||
const themeInitPath = path.join(ui.getTemplatesDir(), 'partials', 'theme-init.js');
|
||||
const themeInitScript = (await fs.exists(themeInitPath))
|
||||
? `<script>${await nativeFs.promises.readFile(themeInitPath, 'utf8')}</script>`
|
||||
: '';
|
||||
|
||||
// Footer Processing
|
||||
const footerHtml = config.footer?.content ? mdProcessor.renderInline(config.footer.content) : '';
|
||||
|
||||
// --- 1. Identify Assets (Plugin + Template Injection) ---
|
||||
// Each entry is `{ priority, gen }` so we can sort by load order:
|
||||
// 0 = base (docmd-main.css)
|
||||
// 5 = theme colour overlay (docmd-theme-sky.css etc.)
|
||||
// 10 = template structure (NEW in 0.8.7)
|
||||
// 15 = user customCss (always wins)
|
||||
// 20 = other plugins
|
||||
// Within the same priority, the order of registration is preserved.
|
||||
//
|
||||
// Assets fall into two buckets per position:
|
||||
// - `commonXxx` — emitted on EVERY page (legacy behaviour)
|
||||
// - `condXxx` — gated by an AssetCondition; evaluated per page
|
||||
type AssetEntry = { priority: number; gen: (rel: string) => string; condition?: any };
|
||||
const assetTags: { head: AssetEntry[]; body: AssetEntry[] } = {
|
||||
head: [],
|
||||
body: [],
|
||||
};
|
||||
|
||||
// Theme CSS (priority 5 — overrides base, overridden by template & customCss)
|
||||
// Skipped when:
|
||||
// - name is 'default' (no overlay) or 'none' (explicitly disabled)
|
||||
// - name was promoted to a template name (see config-schema §4.0)
|
||||
// - the template opted out of CSS overlay via _noCssOverlay
|
||||
const themeName = config.theme.name;
|
||||
const themeIsTemplate = config.theme._noCssOverlay === true;
|
||||
if (themeName && themeName !== 'default' && themeName !== 'none' && !themeIsTemplate) {
|
||||
assetTags.head.push({
|
||||
priority: 5,
|
||||
gen: (rel: string) => generateAssetTag(`${rel}assets/css/docmd-theme-${themeName}.css?v=${buildHash}`, 'css'),
|
||||
});
|
||||
}
|
||||
// Lightbox (priority 20 — always last, no theme/template should override it)
|
||||
assetTags.body.push({
|
||||
priority: 20,
|
||||
gen: (rel: string) => generateAssetTag(`${rel}assets/js/docmd-image-lightbox.js?v=${buildHash}`, 'js'),
|
||||
});
|
||||
|
||||
// Template assets (priority 10 by default — overrides theme, overridden by customCss)
|
||||
if (hooks.templateAssets && Array.isArray(hooks.templateAssets)) {
|
||||
for (const asset of hooks.templateAssets) {
|
||||
if (!asset || !asset.path || (asset.type !== 'css' && asset.type !== 'js')) continue;
|
||||
const baseName = path.basename(asset.path);
|
||||
const position = (asset.position || (asset.type === 'css' ? 'head' : 'body')) as 'head' | 'body';
|
||||
const priority = typeof asset.priority === 'number' ? asset.priority : 10;
|
||||
const bucket = position === 'head' ? assetTags.head : assetTags.body;
|
||||
// The URL is computed per-page (relativePathToRoot varies by depth).
|
||||
bucket.push({
|
||||
priority,
|
||||
gen: (rel: string) => generateAssetTag(`${rel}assets/template/${baseName}?v=${buildHash}`, asset.type),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin Assets
|
||||
if (hooks.assets) {
|
||||
for (const getAssetsFn of hooks.assets) {
|
||||
// hooks.assets entries are async wrappers; missing the await here
|
||||
// would make `assets` a Promise and silently skip the whole tag
|
||||
// generation loop (Array.isArray(Promise) === false). The
|
||||
// user-visible symptom is "plugin <script>/<link> tags never appear
|
||||
// in rendered HTML" — search modal can't open, git commit history
|
||||
// widget never initialises, mermaid/math never hydrate.
|
||||
const assets = await getAssetsFn();
|
||||
if (Array.isArray(assets)) {
|
||||
for (const asset of assets) {
|
||||
let tagGen;
|
||||
if (asset.src && asset.dest) {
|
||||
// Copy is handled in build.js main loop, here we just ref tags
|
||||
// location: 'none' means copy the file but don't inject any tag
|
||||
if (asset.location !== 'none') {
|
||||
tagGen = (rel: string) => generateAssetTag(`${rel}${asset.dest}?v=${buildHash}`, asset.type, asset.attributes);
|
||||
}
|
||||
} else if (asset.url) {
|
||||
tagGen = () => generateAssetTag(asset.url, asset.type, asset.attributes);
|
||||
}
|
||||
if (tagGen) {
|
||||
// Plugin assets without an explicit priority land at 20 (last).
|
||||
const priority = typeof asset.priority === 'number' ? asset.priority : 20;
|
||||
const bucket = asset.location === 'head' ? assetTags.head : assetTags.body;
|
||||
bucket.push({ priority, gen: tagGen, condition: (asset as any).condition });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort each bucket by priority (stable). Conditional assets stay in the same
|
||||
// list — the per-page renderer filters them via the `condition` predicate.
|
||||
const sortByPriority = (a: { priority: number }, b: { priority: number }) => a.priority - b.priority;
|
||||
assetTags.head.sort(sortByPriority);
|
||||
assetTags.body.sort(sortByPriority);
|
||||
|
||||
// --- 2. Process Content ---
|
||||
// Build a set of file paths that the auto-router designated as folder-level indexes
|
||||
// These are files where _sourceFile was stored when the auto-router reassigned them to '/'
|
||||
const navDesignatedIndexFiles = new Set<string>();
|
||||
const extractNavIndexes = (items: any[]) => {
|
||||
for (const item of items) {
|
||||
if (item.children) {
|
||||
extractNavIndexes(item.children);
|
||||
} else if (item._sourceFile) {
|
||||
// _sourceFile is the original path like '/highlighting/' - normalize to relative path
|
||||
// and strip trailing slash to match relWithoutExt format used during comparison
|
||||
navDesignatedIndexFiles.add(item._sourceFile.replace(/^\//, '').replace(/\/$/, ''));
|
||||
}
|
||||
}
|
||||
};
|
||||
if (config.navigation) extractNavIndexes(config.navigation);
|
||||
|
||||
// Find both .md/.markdown files AND .ejs content files (EJS files are pre-rendered before markdown)
|
||||
// When fallbackSrcDir is set (non-default locale), scan the fallback dir as the canonical
|
||||
// file list, then check the locale dir for overrides per file.
|
||||
const scanDir = fallbackSrcDir || srcDir;
|
||||
const mdFiles = await findFilesRecursive(scanDir, ['.md', '.markdown', '.ejs']);
|
||||
|
||||
// Build set of locale directory names to skip when scanning a non-locale-specific dir
|
||||
// This prevents locale subdirs inside old version dirs from being rendered as regular pages
|
||||
const localeIds = new Set((config._allLocales || []).map((l: any) => l.id));
|
||||
|
||||
// ── Phase 2A: Parallel file reading + content processing ──────
|
||||
//
|
||||
// Instead of sequential read→parse→render per file, we batch the work:
|
||||
// 1. Read files in parallel (I/O bound — benefits from concurrency)
|
||||
// 2. Parse + render markdown (CPU bound — still sequential per-file but batched)
|
||||
// 3. Collect all pages, then render HTML templates + write in parallel
|
||||
|
||||
interface PageEntry {
|
||||
relativePath: string;
|
||||
targetFilePath: string;
|
||||
isFallback: boolean;
|
||||
}
|
||||
|
||||
// Build the file manifest (fast — just path resolution, no I/O)
|
||||
const fileManifest: PageEntry[] = [];
|
||||
|
||||
for (const filePath of mdFiles) {
|
||||
const relativePath = path.relative(scanDir, filePath);
|
||||
|
||||
// Skip files inside locale subdirectories when scanning a non-locale dir
|
||||
if (localeIds.size > 0 && !fallbackSrcDir) {
|
||||
const topDir = relativePath.split(path.sep)[0];
|
||||
if (localeIds.has(topDir)) continue;
|
||||
}
|
||||
|
||||
let targetFilePath = filePath;
|
||||
let isFallback = false;
|
||||
|
||||
// For non-default locales: check if the locale dir has this file
|
||||
if (fallbackSrcDir) {
|
||||
const localizedPath = path.join(srcDir, relativePath);
|
||||
if (nativeFs.existsSync(localizedPath)) {
|
||||
targetFilePath = localizedPath;
|
||||
} else {
|
||||
isFallback = true;
|
||||
}
|
||||
}
|
||||
|
||||
fileManifest.push({ relativePath, targetFilePath, isFallback });
|
||||
}
|
||||
|
||||
// Scan for locale-exclusive pages (exist only in the locale dir, not in fallback)
|
||||
if (fallbackSrcDir && nativeFs.existsSync(srcDir)) {
|
||||
const localeFiles = await findFilesRecursive(srcDir, ['.md', '.markdown', '.ejs']);
|
||||
const fallbackRelPaths = new Set(mdFiles.map(f => path.relative(scanDir, f)));
|
||||
|
||||
for (const filePath of localeFiles) {
|
||||
const relativePath = path.relative(srcDir, filePath);
|
||||
if (fallbackRelPaths.has(relativePath)) continue;
|
||||
fileManifest.push({ relativePath, targetFilePath: filePath, isFallback: false });
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2. Filter by targetFiles (Incremental Build) ---
|
||||
const filteredManifest = targetFiles && targetFiles.length > 0
|
||||
? fileManifest.filter(entry => {
|
||||
// targetFiles are usually absolute or relative to CWD.
|
||||
// We check if the entry's targetFilePath matches any of the targetFiles.
|
||||
return targetFiles.some(t => {
|
||||
const absTarget = path.resolve(process.cwd(), t);
|
||||
return entry.targetFilePath === absTarget || entry.relativePath === t;
|
||||
});
|
||||
})
|
||||
: fileManifest;
|
||||
|
||||
// Total page count for progress reporting
|
||||
const totalFiles = filteredManifest.length;
|
||||
let processedCount = 0;
|
||||
|
||||
// ── Process files in batches ──────────────────────────────────
|
||||
const pages: any[] = [];
|
||||
|
||||
for (let batchStart = 0; batchStart < filteredManifest.length; batchStart += BATCH_SIZE) {
|
||||
const batch = filteredManifest.slice(batchStart, batchStart + BATCH_SIZE);
|
||||
|
||||
// 1. Read all files in this batch concurrently
|
||||
const fileContents = await Promise.all(
|
||||
batch.map(entry => nativeFs.promises.readFile(entry.targetFilePath, 'utf8'))
|
||||
);
|
||||
|
||||
// 2. Process each file in the batch
|
||||
for (let j = 0; j < batch.length; j++) {
|
||||
const { relativePath, targetFilePath, isFallback } = batch[j];
|
||||
let rawContent = fileContents[j];
|
||||
|
||||
// Prepend a warning callout when falling back to default language
|
||||
if (isFallback) {
|
||||
const defaultLabel = config._allLocales?.find((l: any) => l.id === config._defaultLocale)?.label || config._defaultLocale;
|
||||
const activeLabel = config._activeLocale.label;
|
||||
const fallbackMsg = t('fallbackMessage', { active: activeLabel, default: defaultLabel });
|
||||
const callout = `\n::: callout warning\n${fallbackMsg}\n:::\n\n`;
|
||||
// Insert after frontmatter if present, otherwise prepend
|
||||
const fmEnd = rawContent.indexOf('\n---', 1);
|
||||
if (rawContent.startsWith('---') && fmEnd > 0) {
|
||||
const afterFm = fmEnd + 4; // skip \n---
|
||||
rawContent = rawContent.slice(0, afterFm) + '\n' + callout + rawContent.slice(afterFm);
|
||||
} else {
|
||||
rawContent = callout + rawContent;
|
||||
}
|
||||
}
|
||||
|
||||
const filename = path.basename(relativePath).toLowerCase();
|
||||
const ext = path.extname(filename);
|
||||
const isIndex = filename.startsWith('index.');
|
||||
const isReadme = filename === 'readme.md';
|
||||
|
||||
// Pre-render .ejs content files through lite-template before passing to markdown
|
||||
if (ext === '.ejs') {
|
||||
try {
|
||||
const fmRegex = /^(?:---[\r\n]+)([\s\S]*?)(?:[\r\n]+---(?:[\r\n]+|$))/;
|
||||
const fmMatch = rawContent.match(fmRegex);
|
||||
let ejsBody = rawContent;
|
||||
const fmData: Record<string, any> = {};
|
||||
let fmRaw = '';
|
||||
|
||||
if (fmMatch) {
|
||||
fmRaw = fmMatch[0];
|
||||
ejsBody = rawContent.slice(fmRaw.length);
|
||||
const yamlStr = fmMatch[1];
|
||||
for (const line of yamlStr.split('\n')) {
|
||||
const kv = line.match(/^(\w+)\s*:\s*(.+)$/);
|
||||
if (kv) {
|
||||
let val: any = kv[2].trim();
|
||||
if (/^\d+$/.test(val)) val = parseInt(val, 10);
|
||||
else if (val === 'true') val = true;
|
||||
else if (val === 'false') val = false;
|
||||
else val = val.replace(/^["']|["']$/g, '');
|
||||
fmData[kv[1]] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const renderedBody = await parser.renderTemplateAsync(ejsBody, {
|
||||
...fmData
|
||||
}, { filename: targetFilePath });
|
||||
|
||||
if (fmRaw) {
|
||||
rawContent = fmRaw + '\n' + renderedBody;
|
||||
} else {
|
||||
rawContent = renderedBody;
|
||||
}
|
||||
} catch (e) {
|
||||
TUI.warn(`Skipping EJS render error in ${relativePath}: ${(e as any).message}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Treat README.md as index only if no index.md exists in the same folder
|
||||
const hasIndexInFolder = fileManifest.some(entry => {
|
||||
const b = path.basename(entry.targetFilePath).toLowerCase();
|
||||
return b.startsWith('index.') && path.dirname(entry.targetFilePath) === path.dirname(targetFilePath);
|
||||
});
|
||||
|
||||
// Check if the auto-router designated this file as a folder-level index
|
||||
const relWithoutExt = relativePath.replace(/\.(md|markdown|ejs)$/i, '').replace(/\\/g, '/');
|
||||
const isNavDesignatedIndex = navDesignatedIndexFiles.has(relWithoutExt);
|
||||
const effectivelyIndex = isIndex || (isReadme && !hasIndexInFolder) || (isNavDesignatedIndex && !hasIndexInFolder);
|
||||
|
||||
// Determine output path — slugify each path segment so that spaces and
|
||||
// URL-unsafe characters are replaced with hyphens, matching the URLs
|
||||
// generated by buildAutoNav in auto-router.ts. Computed BEFORE
|
||||
// processContentAsync so we can pass relativePathToRoot + isOfflineMode
|
||||
// through env; the markdown post-processor uses these to rewrite
|
||||
// internal hrefs for offline builds (#167 permanent fix).
|
||||
const withoutExt = slugifyOutputPath(
|
||||
relativePath.replace(/\.(md|markdown|ejs)$/, '').replace(/\\/g, '/')
|
||||
);
|
||||
const slugifiedDir = slugifyOutputPath(
|
||||
path.dirname(relativePath).replace(/\\/g, '/')
|
||||
);
|
||||
const htmlOutputPath = effectivelyIndex
|
||||
? path.posix.join(outputPrefix, slugifiedDir, 'index.html').replace(/^\/?/, '')
|
||||
: path.posix.join(outputPrefix, withoutExt, 'index.html').replace(/^\/?/, '');
|
||||
// #167: compute relativePathToRoot from the *output* path so markdown
|
||||
// hrefs that target `/foo/` get rewritten to `./foo/index.html` (or
|
||||
// `../foo/index.html` for nested pages) — works in file:// AND on
|
||||
// HTTP servers AND on custom domains.
|
||||
const fileDir = path.dirname(htmlOutputPath);
|
||||
let relativePathToRootForMarkdown = path.relative(fileDir, '.');
|
||||
if (relativePathToRootForMarkdown === '') relativePathToRootForMarkdown = './';
|
||||
else relativePathToRootForMarkdown += '/';
|
||||
relativePathToRootForMarkdown = relativePathToRootForMarkdown.replace(/\\/g, '/');
|
||||
|
||||
const processed = await parser.processContentAsync(
|
||||
rawContent,
|
||||
mdProcessor,
|
||||
config,
|
||||
{
|
||||
isIndex: effectivelyIndex,
|
||||
filePath: targetFilePath,
|
||||
// #167: offline-mode + relative-path context for the markdown post-processor.
|
||||
isOfflineMode: options.offline === true,
|
||||
relativePathToRoot: relativePathToRootForMarkdown,
|
||||
// M-5: tell the post-processor which locale is the default so it
|
||||
// can strip the default-locale prefix from absolute hrefs
|
||||
// (e.g. `/en/foo` from a `fr/` page → `/foo`, since the default
|
||||
// locale lives at root, not under its own prefix).
|
||||
defaultLocale: config._defaultLocale || null,
|
||||
allLocales: (config._allLocales || []).map((l: any) => l.id),
|
||||
config: { base: config.base || '/' }
|
||||
},
|
||||
hooks
|
||||
);
|
||||
if (!processed) continue;
|
||||
|
||||
pages.push({ ...processed, sourcePath: targetFilePath, outputPath: htmlOutputPath, rawMarkdown: rawContent });
|
||||
}
|
||||
|
||||
// Report progress after each batch
|
||||
processedCount += batch.length;
|
||||
if (onProgress) onProgress(processedCount, totalFiles);
|
||||
}
|
||||
|
||||
// --- 2.5 onBeforeBuild (Data Indexing) ---
|
||||
// Run hooks on every pass (each locale has its own page set).
|
||||
// Show the section header only ONCE per top-level build to avoid
|
||||
// reprinting it for every locale × version combination.
|
||||
if (hooks.onBeforeBuild && hooks.onBeforeBuild.length > 0) {
|
||||
const showSection = !options.targetFiles;
|
||||
const beforeBuildContext = {
|
||||
config,
|
||||
pages,
|
||||
tui: TUI,
|
||||
options: showSection ? { ...options, quiet: options.quiet || false } : options,
|
||||
runWorkerTask(modulePath: string, functionName: string, args: any[]) {
|
||||
if (!config._workerPool) throw new Error('WorkerPool is not initialized');
|
||||
return config._workerPool.runTask({ type: 'plugin-task', modulePath, functionName, args });
|
||||
}
|
||||
};
|
||||
for (const hookFn of hooks.onBeforeBuild) {
|
||||
await hookFn(beforeBuildContext);
|
||||
}
|
||||
// Section stays open — build.ts closes it after appending search.
|
||||
}
|
||||
|
||||
// --- 3. Render HTML (parallel template rendering + batched writes) ---
|
||||
const writeQueue: { finalPath: string; html: string }[] = [];
|
||||
|
||||
// Process pages in batches to allow concurrent hook execution (e.g. Git log calls)
|
||||
for (let i = 0; i < pages.length; i += BATCH_SIZE) {
|
||||
const batch = pages.slice(i, i + BATCH_SIZE);
|
||||
|
||||
await Promise.all(batch.map(async (page) => {
|
||||
const finalPath = path.join(outputDir, page.outputPath);
|
||||
const fileDir = path.dirname(page.outputPath);
|
||||
let relativePathToRoot = path.relative(fileDir, '.');
|
||||
if (relativePathToRoot === '') relativePathToRoot = './';
|
||||
else relativePathToRoot += '/';
|
||||
relativePathToRoot = relativePathToRoot.replace(/\\/g, '/');
|
||||
|
||||
// Navigation Context
|
||||
let navPath = '/' + page.outputPath.replace(/\\/g, '/').replace(/\/index\.html$/, '').replace(/^index\.html$/, '');
|
||||
if (navPath === '/.') navPath = '/';
|
||||
|
||||
// Strip outputPrefix (locale + version) from navPath so it matches navigation.json paths
|
||||
if (outputPrefix) {
|
||||
const prefixStr = '/' + outputPrefix.replace(/\/$/, '');
|
||||
if (navPath.startsWith(prefixStr + '/') || navPath === prefixStr) {
|
||||
navPath = navPath.substring(prefixStr.length) || '/';
|
||||
}
|
||||
}
|
||||
|
||||
const { prevPage, nextPage } = findPageNeighbors(config.navigation, navPath);
|
||||
const breadcrumbs = config.layout?.breadcrumbs !== false ? findBreadcrumbs(config.navigation, navPath) : [];
|
||||
|
||||
// ── Centralized URL Context ──
|
||||
const urlContext = createUrlContext({
|
||||
relativePathToRoot,
|
||||
outputPrefix,
|
||||
offline: options.offline,
|
||||
base: config.base || '/',
|
||||
siteUrl: config.url || '',
|
||||
});
|
||||
|
||||
// Pre-compute page URLs for plugin consumption
|
||||
const pageUrls = computePageUrls(page.outputPath, config.url || '');
|
||||
|
||||
const buildRelativeUrl = (href: string) => buildContextualUrl(href, urlContext);
|
||||
|
||||
// Fix Neighbor Links
|
||||
const fixNeighbor = (node: any) => {
|
||||
if (!node) return null;
|
||||
return { ...node, url: buildRelativeUrl(node.path) };
|
||||
};
|
||||
|
||||
// ── Phase 3A: Invoke onBeforeRender Hook ──
|
||||
// (Run BEFORE asset injection so plugins can mutate page.htmlContent /
|
||||
// page.frontmatter and the conditional asset filter sees the final state.)
|
||||
// D-M3: urlContext and config are now included in onBeforeRender
|
||||
// too, matching the shape onPageReady gets. This lets plugins
|
||||
// that need to build URLs or read global config do so from either
|
||||
// hook without checking which one they're in. The values are
|
||||
// shallow snapshots — mutating them does NOT affect other pages
|
||||
// or the global config.
|
||||
const pageContext = {
|
||||
frontmatter: page.frontmatter,
|
||||
outputPath: page.outputPath,
|
||||
sourcePath: page.sourcePath,
|
||||
urls: pageUrls,
|
||||
html: page.htmlContent,
|
||||
urlContext,
|
||||
config
|
||||
};
|
||||
|
||||
if (hooks.onBeforeRender) {
|
||||
for (const fn of hooks.onBeforeRender) {
|
||||
await fn(pageContext);
|
||||
}
|
||||
}
|
||||
|
||||
// Reflect any mutations from plugins
|
||||
page.htmlContent = pageContext.html;
|
||||
page.frontmatter = pageContext.frontmatter;
|
||||
|
||||
// Evaluate conditional assets against the *final* page state. This is
|
||||
// what makes features like conditional mermaid loading work — a page
|
||||
// that doesn't render any `class="mermaid"` block never gets the
|
||||
// `init-mermaid.js` <script> (and therefore never fetches the ~500 KB
|
||||
// mermaid library from the CDN at runtime).
|
||||
const matchesCondition = (condition: any): boolean => {
|
||||
if (!condition || typeof condition !== 'object') return true;
|
||||
const fm = page.frontmatter || {};
|
||||
const html = page.htmlContent || '';
|
||||
|
||||
if (condition.pageHtmlMatches != null) {
|
||||
const needles = Array.isArray(condition.pageHtmlMatches)
|
||||
? condition.pageHtmlMatches
|
||||
: [condition.pageHtmlMatches];
|
||||
const anyHit = needles.some((n: string) => typeof n === 'string' && n.length > 0 && html.includes(n));
|
||||
if (!anyHit) return false;
|
||||
}
|
||||
if (condition.frontmatterHas != null) {
|
||||
if (typeof condition.frontmatterHas !== 'string') return false;
|
||||
if (!Object.prototype.hasOwnProperty.call(fm, condition.frontmatterHas)) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const renderAssetList = (entries: AssetEntry[]) =>
|
||||
entries
|
||||
.filter(e => matchesCondition(e.condition))
|
||||
.map(e => e.gen(relativePathToRoot))
|
||||
.join('\n');
|
||||
|
||||
const assetHeadHtml = renderAssetList(assetTags.head);
|
||||
const assetBodyHtml = renderAssetList(assetTags.body);
|
||||
|
||||
const headInjections = await Promise.all(hooks.injectHead.map((fn: any) => fn(config, pageContext, relativePathToRoot)));
|
||||
const bodyInjections = await Promise.all(hooks.injectBody.map((fn: any) => fn(config, pageContext)));
|
||||
|
||||
// Phase 1.B (T-S7): the framework does NOT auto-sanitize plugin output.
|
||||
// Plugins are npm-installed and audited per the trust model
|
||||
// (DEVELOPMENT-BENCHMARK.md §S4). The `sanitizeHeadInjection` helper
|
||||
// is exported from @docmd/utils and is available for plugins that want
|
||||
// to sanitise their own output, but the framework does not impose it.
|
||||
// Auto-sanitisation breaks legitimate scripts (e.g. Google Analytics'
|
||||
// self-closing <script src=...></script> tag) because no regex can
|
||||
// reliably distinguish a trusted analytics script from a malicious one.
|
||||
const fullHeadHtml = [
|
||||
headInjections.join('\n'),
|
||||
assetHeadHtml,
|
||||
generateHreflangTags(config, page.outputPath)
|
||||
].join('\n');
|
||||
|
||||
const fullBodyHtml = [
|
||||
assetBodyHtml,
|
||||
bodyInjections.join('\n')
|
||||
].join('\n');
|
||||
|
||||
// Source file path relative to srcDir
|
||||
const sourceRelative = path.relative(process.cwd(), page.sourcePath).replace(/\\/g, '/');
|
||||
|
||||
// Compute edit URL from git plugin config (preferred) or legacy config.editLink
|
||||
let editUrl = null;
|
||||
const editLinkText = config.plugins?.git?.editLinkText || config.editLink?.text || t('editThisPage');
|
||||
const gitPluginConfig = config.plugins?.git;
|
||||
|
||||
if (gitPluginConfig?.repo && gitPluginConfig?.editLink !== false) {
|
||||
// Git plugin config (modern approach)
|
||||
const gitRoot = getGitRoot();
|
||||
const editRelative = gitRoot
|
||||
? path.relative(gitRoot, page.sourcePath).replace(/\\/g, '/')
|
||||
: sourceRelative;
|
||||
const repo = gitPluginConfig.repo.replace(/\/$/, '');
|
||||
const branch = gitPluginConfig.branch || 'main';
|
||||
const editPath = gitPluginConfig.editPath || 'edit';
|
||||
editUrl = `${repo}/${editPath}/${branch}/${editRelative}`;
|
||||
} else if (config.editLink?.enabled && config.editLink?.baseUrl) {
|
||||
// Legacy fallback
|
||||
const cleanBase = config.editLink.baseUrl.replace(/\/$/, '');
|
||||
const gitRoot = getGitRoot();
|
||||
const editRelative = gitRoot
|
||||
? path.relative(gitRoot, page.sourcePath).replace(/\\/g, '/')
|
||||
: path.relative(path.resolve(process.cwd(), config.src || '.'), page.sourcePath).replace(/\\/g, '/');
|
||||
editUrl = `${cleanBase}/${editRelative}`;
|
||||
}
|
||||
|
||||
// Navigation HTML
|
||||
const navigationHtml = await parser.renderTemplateAsync(templates.navigation, {
|
||||
config,
|
||||
navItems: config.navigation,
|
||||
currentPagePath: navPath,
|
||||
relativePathToRoot,
|
||||
outputPrefix,
|
||||
isOfflineMode: options.offline,
|
||||
buildRelativeUrl,
|
||||
t
|
||||
}, { filename: ui.getTemplatePath('navigation') });
|
||||
|
||||
// Render Full Page
|
||||
// Template selection (new in 0.8.7): resolveTemplate() honours
|
||||
// frontmatter.template > config.templates[glob] > config.theme.template > default
|
||||
// and falls back to the default layout if the resolved file is missing.
|
||||
// noStyle pages (no chrome) continue to use templates.noStyle as before.
|
||||
let templateString = templates.layout;
|
||||
let resolvedTemplateInfo: any = null;
|
||||
if (page.frontmatter.noStyle) {
|
||||
templateString = templates.noStyle;
|
||||
} else {
|
||||
try {
|
||||
const resolved = ui.resolveTemplate({
|
||||
type: 'layout',
|
||||
pagePath: page.outputPath,
|
||||
frontmatter: page.frontmatter,
|
||||
config,
|
||||
localeId: config._activeLocale?.id,
|
||||
versionId: config._activeVersion?.id,
|
||||
});
|
||||
if (resolved && resolved.templatePath) {
|
||||
templateString = await nativeFs.promises.readFile(resolved.templatePath, 'utf8');
|
||||
resolvedTemplateInfo = resolved;
|
||||
}
|
||||
} catch (e: any) {
|
||||
TUI.warn(`Template resolver failed for "${page.outputPath}" — falling back to default. ${e.message}`);
|
||||
}
|
||||
}
|
||||
let fullHtml = await parser.renderTemplateAsync(templateString, {
|
||||
content: page.htmlContent,
|
||||
rawMarkdown: page.rawMarkdown || '',
|
||||
frontmatter: page.frontmatter,
|
||||
headings: page.headings,
|
||||
config,
|
||||
buildHash,
|
||||
coreVersion: coreVersion || CORE_VERSION,
|
||||
siteTitle: config.title,
|
||||
pageTitle: page.frontmatter.title,
|
||||
description: page.frontmatter.description || '',
|
||||
appearance: config.theme?.appearance || config.theme?.defaultMode || 'system',
|
||||
defaultMode: config.theme?.appearance || config.theme?.defaultMode || 'system',
|
||||
relativePathToRoot,
|
||||
isOfflineMode: options.offline,
|
||||
buildRelativeUrl,
|
||||
navigationHtml,
|
||||
prevPage: fixNeighbor(prevPage),
|
||||
nextPage: fixNeighbor(nextPage),
|
||||
logo: config.logo,
|
||||
theme: config.theme,
|
||||
|
||||
headerConfig: config.header,
|
||||
sidebarConfig: config.sidebar,
|
||||
footerConfig: config.footer,
|
||||
menubarConfig: config.menubar,
|
||||
optionsMenu: config.optionsMenu,
|
||||
|
||||
customCssFiles: config.theme.customCss || [],
|
||||
customJsFiles: config.customJs || [],
|
||||
|
||||
pluginHeadScriptsHtml: fullHeadHtml,
|
||||
pluginBodyScriptsHtml: fullBodyHtml,
|
||||
|
||||
faviconLinkHtml: config.favicon ? `<link id="site-favicon" rel="icon" href="${relativePathToRoot}${config.favicon.replace(/^\//, '')}?v=${buildHash}">` : '',
|
||||
themeInitScript,
|
||||
footerHtml,
|
||||
isActivePage: page.htmlContent && page.htmlContent.trim().length > 0,
|
||||
editUrl,
|
||||
editLinkText,
|
||||
breadcrumbs,
|
||||
sourceFile: sourceRelative,
|
||||
activeLocale: config._activeLocale || null,
|
||||
allLocales: config._allLocales || null,
|
||||
builtLocales: config._builtLocales ? [...config._builtLocales] : null,
|
||||
defaultLocale: config._defaultLocale || null,
|
||||
i18nInPlace: config.i18n?.inPlace || false,
|
||||
i18nStringMode: config.i18n?.stringMode || false,
|
||||
localePrefix: config._localeOutputPrefix || '',
|
||||
currentPagePath: navPath,
|
||||
outputPrefix,
|
||||
siteRootAbs: (() => {
|
||||
let root = '/';
|
||||
if (config._activePrefix && config._activePrefix !== '/') {
|
||||
root = config._activePrefix;
|
||||
} else if (config.base && config.base !== '/') {
|
||||
root = config.base;
|
||||
}
|
||||
if (root !== '/' && !root.endsWith('/')) root += '/';
|
||||
return root;
|
||||
})(),
|
||||
t,
|
||||
buildAbsoluteUrl,
|
||||
sanitizeUrl,
|
||||
workspace: config._workspace,
|
||||
themeCssLinkHtml: '',
|
||||
metaTagsHtml: '',
|
||||
pluginStylesHtml: '',
|
||||
// New in 0.8.7: resolved template metadata, available to the
|
||||
// template if it needs to know which template handled this page.
|
||||
_template: resolvedTemplateInfo,
|
||||
}, { filename: ui.getTemplatePath('layout') });
|
||||
|
||||
const pageObj = {
|
||||
html: fullHtml,
|
||||
frontmatter: page.frontmatter,
|
||||
outputPath: page.outputPath,
|
||||
sourcePath: page.sourcePath,
|
||||
urls: pageUrls,
|
||||
urlContext,
|
||||
config
|
||||
};
|
||||
|
||||
for (const fn of hooks.onPageReady) {
|
||||
await fn(pageObj);
|
||||
}
|
||||
|
||||
fullHtml = pageObj.html;
|
||||
|
||||
// Queue the write
|
||||
writeQueue.push({ finalPath, html: fullHtml });
|
||||
(page as any).urls = pageUrls;
|
||||
}));
|
||||
}
|
||||
|
||||
// --- 4. Parallel file writes ───────────────────────────────────
|
||||
// Write all rendered HTML files in batches for maximum I/O throughput
|
||||
for (let i = 0; i < writeQueue.length; i += WRITE_BATCH_SIZE) {
|
||||
const writeBatch = writeQueue.slice(i, i + WRITE_BATCH_SIZE);
|
||||
await Promise.all(
|
||||
writeBatch.map(async ({ finalPath, html }) => {
|
||||
await fs.ensureDir(path.dirname(finalPath));
|
||||
await nativeFs.promises.writeFile(finalPath, html);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* @package @docmd/core (and ecosystem)
|
||||
* @website https://docmd.io
|
||||
* @repository https://github.com/docmd-io/docmd
|
||||
* @license MIT
|
||||
* @copyright Copyright (c) 2025-present docmd.io
|
||||
*
|
||||
* [docmd-source] - Please do not remove this header.
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { TUI } from '@docmd/tui';
|
||||
import nativeFs from 'fs';
|
||||
import { fsUtils as fs } from '@docmd/utils';
|
||||
import { renderPages } from './generator.js';
|
||||
import { buildVersions, filterGhostVersions } from './versioning.js';
|
||||
import { sanitizeUrl } from '@docmd/parser';
|
||||
import { findFilesRecursive } from './assets.js';
|
||||
|
||||
/**
|
||||
* Pre-count total pages across all locale × version passes.
|
||||
* Fast scan (just directory walks, no file reads) so the progress bar
|
||||
* knows the exact denominator from the very first page.
|
||||
*/
|
||||
export async function preCountPages(config: any, CWD: string, targetFiles?: string[] | null): Promise<number> {
|
||||
if (targetFiles && targetFiles.length > 0) return targetFiles.length;
|
||||
const locales = getLocales(config);
|
||||
const isStringMode = config.i18n?.stringMode === true;
|
||||
let total = 0;
|
||||
|
||||
for (const locale of locales) {
|
||||
const localeId = locale ? locale.id : null;
|
||||
const isDefault = localeId ? localeId === config.i18n.default : false;
|
||||
if (isStringMode && localeId && !isDefault) continue;
|
||||
|
||||
const localeConfig = createLocaleConfig(config, locale);
|
||||
const versions = localeConfig.versions?.all?.length > 0
|
||||
? localeConfig.versions.all
|
||||
: [null];
|
||||
|
||||
for (const v of versions) {
|
||||
const baseSrcDir = v
|
||||
? path.resolve(CWD, v.dir)
|
||||
: path.resolve(CWD, localeConfig.src);
|
||||
|
||||
const localeSrcDir = resolveLocaleSrcDir(baseSrcDir, localeConfig);
|
||||
const fallbackSrcDir = resolveFallbackSrcDir(baseSrcDir, localeConfig);
|
||||
|
||||
// Match the skip logic from buildVersions (line 164-169) and buildLocales (line 271-274):
|
||||
// If the locale-specific dir doesn't exist, only the default locale processes.
|
||||
if (!nativeFs.existsSync(localeSrcDir)) {
|
||||
if (!isDefault && localeId) continue; // non-default locale: skip
|
||||
// Default locale: try the base dir directly (old versions without locale dirs)
|
||||
if (v && nativeFs.existsSync(baseSrcDir)) {
|
||||
const files = await findFilesRecursive(baseSrcDir, ['.md', '.markdown', '.ejs']);
|
||||
total += files.length;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Scan fallback dir when set (non-default locale uses default locale as canonical list)
|
||||
const scanDir = fallbackSrcDir || localeSrcDir;
|
||||
if (!nativeFs.existsSync(scanDir)) continue;
|
||||
|
||||
const files = await findFilesRecursive(scanDir, ['.md', '.markdown', '.ejs']);
|
||||
total += files.length;
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare locale context to inject into config for a build pass.
|
||||
* When locale is null (i18n disabled), returns the config unchanged.
|
||||
*
|
||||
* Design: every locale lives in its own subdirectory inside the src dir:
|
||||
* docs/en/ docs/hi/ docs/zh/
|
||||
*
|
||||
* The default locale renders at root (no URL prefix),
|
||||
* non-default locales render at /{locale}/.
|
||||
* Fallback: if a page doesn't exist in a non-default locale dir,
|
||||
* the engine falls back to the default locale's version of that page.
|
||||
*/
|
||||
export function createLocaleConfig(config: any, locale: any): any {
|
||||
if (!locale) return config;
|
||||
const isDefault = locale.id === config.i18n.default;
|
||||
return {
|
||||
...config,
|
||||
_activeLocale: locale,
|
||||
_allLocales: config.i18n.locales,
|
||||
_defaultLocale: config.i18n.default,
|
||||
_localeOutputPrefix: isDefault ? '' : locale.id + '/'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the source directory for a given locale.
|
||||
* When i18n is enabled with directory mode, each locale gets its own subdirectory: {baseSrcDir}/{localeId}/
|
||||
* When stringMode is enabled, all locales use the same root source directory.
|
||||
* When i18n is disabled, returns baseSrcDir unchanged.
|
||||
*/
|
||||
export function resolveLocaleSrcDir(baseSrcDir: string, config: any): string {
|
||||
if (!config._activeLocale) return baseSrcDir;
|
||||
if (config.i18n?.stringMode) return baseSrcDir;
|
||||
return path.join(baseSrcDir, config._activeLocale.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the fallback source directory (the default locale's dir).
|
||||
* Used when a non-default locale is missing a page - falls back to the default locale.
|
||||
* Returns null if current locale IS the default (no fallback needed).
|
||||
* Returns null in stringMode (all locales share the same source).
|
||||
*/
|
||||
export function resolveFallbackSrcDir(baseSrcDir: string, config: any): string | null {
|
||||
if (!config._activeLocale || !config._defaultLocale) return null;
|
||||
if (config.i18n?.stringMode) return null;
|
||||
if (config._activeLocale.id === config._defaultLocale) return null;
|
||||
return path.join(baseSrcDir, config._defaultLocale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of locales to iterate over.
|
||||
* Returns [null] when i18n is disabled (single pass, no locale prefix).
|
||||
*/
|
||||
export function getLocales(config: any): any[] {
|
||||
return config.i18n && config.i18n.locales ? config.i18n.locales : [null];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build all locales - the outer loop of the build pipeline.
|
||||
* For each locale, runs the versioning loop (or standard build) inside it.
|
||||
*
|
||||
* The default locale builds directly into rootOutputDir (no prefix).
|
||||
* Non-default locales build into rootOutputDir/{locale}/.
|
||||
*/
|
||||
export async function buildLocales({
|
||||
config,
|
||||
rootOutputDir,
|
||||
hooks,
|
||||
buildHash,
|
||||
options,
|
||||
CWD,
|
||||
onProgress,
|
||||
targetFiles,
|
||||
coreVersion
|
||||
}: {
|
||||
config: any;
|
||||
rootOutputDir: string;
|
||||
hooks: any;
|
||||
buildHash: string;
|
||||
options: any;
|
||||
CWD: string;
|
||||
onProgress?: (current: number, total: number) => void;
|
||||
targetFiles?: string[];
|
||||
coreVersion?: string;
|
||||
}): Promise<any[]> {
|
||||
const allGeneratedPages = [];
|
||||
|
||||
// Filter ghost versions once before any locale pass
|
||||
await filterGhostVersions(config, CWD, options.isDev);
|
||||
|
||||
const locales = getLocales(config);
|
||||
const isStringMode = config.i18n?.stringMode === true;
|
||||
|
||||
// Pre-scan which locale directories actually exist so the language switcher
|
||||
// can disable unavailable locales during rendering (before pages are built).
|
||||
// The default locale is always "available" since it renders at root.
|
||||
if (config.i18n && config.i18n.locales) {
|
||||
const baseSrcDir = path.resolve(CWD, config.src);
|
||||
const availableLocaleIds = new Set<string>();
|
||||
|
||||
for (const loc of config.i18n.locales) {
|
||||
if (loc.id === config.i18n.default) {
|
||||
availableLocaleIds.add(loc.id);
|
||||
continue;
|
||||
}
|
||||
if (isStringMode) {
|
||||
// stringMode: all locales are available (they clone the default)
|
||||
availableLocaleIds.add(loc.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for locale directory - handle both versioned and non-versioned structures
|
||||
// Non-versioned: baseSrcDir/{localeId}/
|
||||
// Versioned: baseSrcDir/{versionDir}/{localeId}/
|
||||
const locDir = path.join(baseSrcDir, loc.id);
|
||||
if (nativeFs.existsSync(locDir)) {
|
||||
availableLocaleIds.add(loc.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check versioned structure: look in the current version's directory
|
||||
if (config.versions?.all?.length > 0) {
|
||||
const currentVersion = config.versions.all.find((v: any) => v.id === config.versions.current);
|
||||
if (currentVersion) {
|
||||
const versionedLocDir = path.join(baseSrcDir, currentVersion.dir, loc.id);
|
||||
if (nativeFs.existsSync(versionedLocDir)) {
|
||||
availableLocaleIds.add(loc.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
config._builtLocales = availableLocaleIds;
|
||||
}
|
||||
|
||||
// In stringMode, build the default locale first (or single pass),
|
||||
// then clone its output with server-side string replacements for other locales.
|
||||
let defaultPassPages: any[] | null = null;
|
||||
|
||||
for (const locale of locales) {
|
||||
const localeId = locale ? locale.id : null;
|
||||
const isDefault = localeId ? localeId === config.i18n.default : false;
|
||||
const localeConfig = createLocaleConfig(config, locale);
|
||||
|
||||
// We pass the rootOutputDir so that path.rel() accurately maps back to root.
|
||||
// The nesting is handled purely by the string pathPrefix.
|
||||
const pathPrefix = (localeId && !isDefault) ? localeId + '/' : '';
|
||||
|
||||
if (isStringMode && localeId && !isDefault) {
|
||||
// stringMode: clone default locale output with string replacements
|
||||
if (!defaultPassPages) {
|
||||
TUI.warn(`stringMode: no default locale pages to clone for ${localeId}. Skipping...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const assetsDir = config.assets || 'assets';
|
||||
const strings = await loadStringModeTranslations(CWD, assetsDir, localeId);
|
||||
const localeDir = locale?.dir || 'ltr';
|
||||
|
||||
if (Object.keys(strings).length === 0) {
|
||||
TUI.warn(`No string translations found for ${localeId} (assets/i18n/${localeId}.json). Rendering default language.`);
|
||||
}
|
||||
|
||||
for (const page of defaultPassPages) {
|
||||
// Re-read the rendered default HTML and apply string replacements
|
||||
const defaultOutputPath = path.join(rootOutputDir, page.outputPath);
|
||||
if (!nativeFs.existsSync(defaultOutputPath)) continue;
|
||||
|
||||
const defaultHtml = await nativeFs.promises.readFile(defaultOutputPath, 'utf8');
|
||||
const translatedHtml = applyStringModeReplacements(defaultHtml, strings, localeId, localeDir);
|
||||
|
||||
// Write to the locale-prefixed output path
|
||||
const localeOutputPath = path.join(rootOutputDir, pathPrefix, page.outputPath);
|
||||
await fs.ensureDir(path.dirname(localeOutputPath));
|
||||
await nativeFs.promises.writeFile(localeOutputPath, translatedHtml);
|
||||
|
||||
allGeneratedPages.push({ ...page, outputPath: pathPrefix + page.outputPath });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (localeConfig.versions?.all?.length > 0) {
|
||||
// Versioned build within this locale
|
||||
const pages = await buildVersions({
|
||||
config: localeConfig,
|
||||
outputDir: rootOutputDir,
|
||||
hooks,
|
||||
buildHash,
|
||||
options,
|
||||
CWD,
|
||||
pathPrefix,
|
||||
onProgress,
|
||||
targetFiles,
|
||||
coreVersion
|
||||
});
|
||||
allGeneratedPages.push(...pages);
|
||||
if (isStringMode && isDefault) defaultPassPages = pages;
|
||||
|
||||
} else {
|
||||
// Standard build (no versioning) within this locale
|
||||
const baseSrcDir = path.resolve(CWD, localeConfig.src);
|
||||
const localeSrcDir = resolveLocaleSrcDir(baseSrcDir, localeConfig);
|
||||
const fallbackSrcDir = resolveFallbackSrcDir(baseSrcDir, localeConfig);
|
||||
|
||||
// The locale dir must exist (or fall back to base when no i18n)
|
||||
if (!await fs.exists(localeSrcDir)) {
|
||||
if (localeConfig._activeLocale && !isStringMode) {
|
||||
TUI.warn(`Locale directory missing: ${localeSrcDir}. Skipping ${localeConfig._activeLocale.id}...`);
|
||||
continue;
|
||||
} else if (!localeConfig._activeLocale) {
|
||||
throw new Error(`Source directory not found: ${localeSrcDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
const pages = await renderPages({
|
||||
config: localeConfig,
|
||||
srcDir: localeSrcDir,
|
||||
fallbackSrcDir,
|
||||
outputDir: rootOutputDir,
|
||||
hooks,
|
||||
buildHash,
|
||||
options,
|
||||
outputPrefix: pathPrefix,
|
||||
onProgress,
|
||||
targetFiles,
|
||||
coreVersion
|
||||
});
|
||||
allGeneratedPages.push(...pages);
|
||||
if (isStringMode && (isDefault || !localeId)) defaultPassPages = pages;
|
||||
}
|
||||
}
|
||||
|
||||
return allGeneratedPages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the root redirect page for i18n sites.
|
||||
*
|
||||
* Since the default locale now renders at root, the redirect is only needed
|
||||
* if the user's browser locale matches a non-default locale. The redirect
|
||||
* page is written as a lightweight JS snippet that checks localStorage →
|
||||
* navigator.language; if it matches a non-default locale, it redirects.
|
||||
* Otherwise, the root content (default locale) is already there.
|
||||
*
|
||||
* Note: This is now a no-op because the default locale is at root.
|
||||
* Users browsing to / get the default locale directly. The language
|
||||
* switcher handles navigation to non-default locales.
|
||||
*/
|
||||
export async function generateLocaleRedirect(config: any, _rootOutputDir: string): Promise<void> {
|
||||
// Default locale is at root - no redirect needed.
|
||||
// The language switcher provides navigation to /hi/, /zh/, etc.
|
||||
if (!config.i18n?.locales) return;
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate hreflang link tags for a page across all locales.
|
||||
* Used by the generator to inject into <head>.
|
||||
*
|
||||
* Default locale pages are at root (no prefix).
|
||||
* Non-default locale pages are at /{locale}/path.
|
||||
*/
|
||||
export function generateHreflangTags(config: any, pageOutputPath: string): string {
|
||||
if (!config._allLocales) return '';
|
||||
|
||||
const base = config.base && config.base !== '/' ? config.base.replace(/\/$/, '') : '';
|
||||
let pagePath = pageOutputPath.replace(/index\.html$/, '').replace(/\\/g, '/');
|
||||
|
||||
// Strip the current locale prefix from pagePath if present.
|
||||
// pageOutputPath may be "hi/guide/index.html" - we need just "guide/".
|
||||
const defaultLocale = config._defaultLocale;
|
||||
for (const loc of config._allLocales) {
|
||||
if (loc.id !== defaultLocale && pagePath.startsWith(loc.id + '/')) {
|
||||
pagePath = pagePath.slice(loc.id.length + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return config._allLocales.map((loc: any) => {
|
||||
const isDefault = loc.id === config._defaultLocale;
|
||||
// Default locale → root path, non-default → /{locale}/path
|
||||
// Use sanitizeUrl to ensure no double slashes
|
||||
const href = sanitizeUrl(isDefault
|
||||
? `${base}/${pagePath}`
|
||||
: `${base}/${loc.id}/${pagePath}`);
|
||||
let tags = `<link rel="alternate" hreflang="${loc.id}" href="${href}">`;
|
||||
if (isDefault) {
|
||||
tags += `\n<link rel="alternate" hreflang="x-default" href="${href}">`;
|
||||
}
|
||||
return tags;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load string-mode translations from assets/i18n/{localeId}.json.
|
||||
* Returns an empty object if the file doesn't exist (graceful fallback).
|
||||
*/
|
||||
export async function loadStringModeTranslations(
|
||||
CWD: string,
|
||||
assetsDir: string,
|
||||
localeId: string
|
||||
): Promise<Record<string, string>> {
|
||||
const filePath = path.join(CWD, assetsDir, 'i18n', `${localeId}.json`);
|
||||
try {
|
||||
if (nativeFs.existsSync(filePath)) {
|
||||
const raw = await nativeFs.promises.readFile(filePath, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
} catch (e: any) {
|
||||
TUI.warn(`Failed to load string-mode translations: ${filePath} - ${e.message}`);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply server-side string replacement on rendered HTML.
|
||||
* Resolves data-i18n, data-i18n-html, and data-i18n-{attr} attributes
|
||||
* using the provided translations object.
|
||||
*
|
||||
* This is the build-time equivalent of docmd-i18n-strings.js - it produces
|
||||
* fully translated HTML that search engines can index.
|
||||
*/
|
||||
export function applyStringModeReplacements(html: string, strings: Record<string, string>, localeId: string, localeDir?: string): string {
|
||||
if (!strings || Object.keys(strings).length === 0) return html;
|
||||
|
||||
// 1. data-i18n="key" → replace textContent
|
||||
// Match: <tag ... data-i18n="key" ...>content</tag>
|
||||
html = html.replace(
|
||||
/(<[^>]+\sdata-i18n="([^"]+)"[^>]*>)([\s\S]*?)(<\/[a-zA-Z][a-zA-Z0-9]*>)/g,
|
||||
(match, openTag, key, _content, closeTag) => {
|
||||
if (strings[key] !== undefined) {
|
||||
return openTag + strings[key] + closeTag;
|
||||
}
|
||||
return match;
|
||||
}
|
||||
);
|
||||
|
||||
// 2. data-i18n-html="key" → replace innerHTML
|
||||
html = html.replace(
|
||||
/(<[^>]+\sdata-i18n-html="([^"]+)"[^>]*>)([\s\S]*?)(<\/[a-zA-Z][a-zA-Z0-9]*>)/g,
|
||||
(match, openTag, key, _content, closeTag) => {
|
||||
if (strings[key] !== undefined) {
|
||||
return openTag + strings[key] + closeTag;
|
||||
}
|
||||
return match;
|
||||
}
|
||||
);
|
||||
|
||||
// 3. data-i18n-{attr}="key" → replace the target attribute value
|
||||
// e.g. data-i18n-placeholder="search.placeholder" placeholder="Search..."
|
||||
html = html.replace(
|
||||
/(<[^>]+)\sdata-i18n-(?!html)([a-zA-Z][\w-]*)="([^"]+)"([^>]*>)/g,
|
||||
(match, before, targetAttr, key, after) => {
|
||||
if (strings[key] !== undefined) {
|
||||
// Replace the target attribute's value
|
||||
const attrRegex = new RegExp(`(${targetAttr})="[^"]*"`);
|
||||
const fullTag = before + ' data-i18n-' + targetAttr + '="' + key + '"' + after;
|
||||
if (attrRegex.test(fullTag)) {
|
||||
return fullTag.replace(attrRegex, `$1="${strings[key]}"`);
|
||||
}
|
||||
}
|
||||
return match;
|
||||
}
|
||||
);
|
||||
|
||||
// 4. Update <html lang="..."> to the target locale
|
||||
html = html.replace(/<html\s+lang="[^"]*"/, `<html lang="${localeId}"`);
|
||||
|
||||
// 5. Add dir attribute for RTL locales
|
||||
if (localeDir && localeDir !== 'ltr') {
|
||||
html = html.replace(/<html\s+lang="[^"]*"/, `$& dir="${localeDir}"`);
|
||||
}
|
||||
|
||||
// 6. Update DOCMD_LOCALE to the target locale
|
||||
html = html.replace(
|
||||
/window\.DOCMD_LOCALE\s*=\s*"[^"]*"/,
|
||||
`window.DOCMD_LOCALE = "${localeId}"`
|
||||
);
|
||||
|
||||
return html;
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* @package @docmd/core (and ecosystem)
|
||||
* @website https://docmd.io
|
||||
* @repository https://github.com/docmd-io/docmd
|
||||
* @license MIT
|
||||
* @copyright Copyright (c) 2025-present docmd.io
|
||||
*
|
||||
* [docmd-source] - Please do not remove this header.
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import nodeFs from 'fs';
|
||||
import { fsUtils as fs } from '@docmd/utils';
|
||||
import { TUI } from '@docmd/tui';
|
||||
import { renderPages } from './generator.js';
|
||||
import { resolveLocaleSrcDir, resolveFallbackSrcDir } from './i18n.js';
|
||||
import { normalizeNavPaths } from '@docmd/parser';
|
||||
|
||||
/**
|
||||
* Filter out "ghost" versions - configured versions whose source directories
|
||||
* don't actually exist on disk. Mutates `config.versions.all` in place.
|
||||
*
|
||||
* T-Z6: if the *current* version's directory is missing, that's a hard
|
||||
* error (not just a warning) — the current version is what users see
|
||||
* by default, so a missing directory silently produces a 0-page build
|
||||
* with no actionable message. The build aborts with a clear pointer
|
||||
* to the missing directory and the version id.
|
||||
*/
|
||||
export async function filterGhostVersions(config: any, CWD: string, isDev: boolean) {
|
||||
if (!config.versions?.all) return;
|
||||
|
||||
const validVersions = [];
|
||||
for (const v of config.versions.all) {
|
||||
const vSrcDir = path.resolve(CWD, v.dir);
|
||||
if (await fs.exists(vSrcDir)) {
|
||||
validVersions.push(v);
|
||||
} else {
|
||||
// T-Z6: missing current version directory is a hard error.
|
||||
// Old (non-current) versions are still soft-warns.
|
||||
const isCurrent = v.id === config.versions.current;
|
||||
if (isCurrent) {
|
||||
throw new Error(
|
||||
`Current version directory missing: ${vSrcDir} (version: ${v.id}). ` +
|
||||
`The "current" version is what users see by default; the build cannot ` +
|
||||
`continue without it. Either create the directory or point "current" ` +
|
||||
`at a version that exists.`
|
||||
);
|
||||
}
|
||||
if (!isDev) TUI.warn(`Skipping missing version: ${v.id} (${v.dir})`);
|
||||
}
|
||||
}
|
||||
config.versions.all = validVersions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart filter: remove navigation items that point to files which don't exist
|
||||
* in a specific version directory. Prevents broken links when pages were
|
||||
* added or removed between versions.
|
||||
*
|
||||
* When fallbackSrcDir is provided (i18n with inherited pages), the filter
|
||||
* keeps nav items that exist in EITHER the locale dir or the fallback dir,
|
||||
* since fallback pages are generated with a "not translated" warning.
|
||||
*/
|
||||
export function filterNavForVersion(items: any[], vSrcDir: string, fallbackSrcDir?: string | null): any[] {
|
||||
return items.reduce((acc, item) => {
|
||||
const newItem = { ...item };
|
||||
|
||||
if (newItem.children) {
|
||||
newItem.children = filterNavForVersion(newItem.children, vSrcDir, fallbackSrcDir);
|
||||
}
|
||||
|
||||
if (newItem.path && !newItem.path.startsWith('http') && !newItem.external) {
|
||||
let relativeFilePath = newItem.path.replace(/^\//, '');
|
||||
// Reverse clean-URL normalisation to find the source file
|
||||
if (relativeFilePath.endsWith('/') || relativeFilePath === '') {
|
||||
// Trailing slash (or root) → could be folder/index.md or folder/README.md or file.md
|
||||
const dir = relativeFilePath.replace(/\/$/, '');
|
||||
const fileCandidate = dir ? dir + '.md' : '';
|
||||
const indexCandidates = dir
|
||||
? [dir + '/index.md', dir + '/README.md', dir + '/readme.md', fileCandidate]
|
||||
: ['index.md', 'README.md', 'readme.md'];
|
||||
|
||||
const found = indexCandidates.find(c => {
|
||||
if (!c) return false;
|
||||
const abs = path.join(vSrcDir, c);
|
||||
if (nodeFs.existsSync(abs)) return true;
|
||||
if (fallbackSrcDir && nodeFs.existsSync(path.join(fallbackSrcDir, c))) return true;
|
||||
return false;
|
||||
});
|
||||
relativeFilePath = found || (dir ? dir + '/index.md' : 'index.md');
|
||||
} else if (!relativeFilePath.endsWith('.md')) {
|
||||
relativeFilePath += '.md';
|
||||
}
|
||||
|
||||
const absoluteFilePath = path.join(vSrcDir, relativeFilePath);
|
||||
try {
|
||||
if (!nodeFs.existsSync(absoluteFilePath)) {
|
||||
// If a fallback dir exists, check there too before removing
|
||||
if (fallbackSrcDir) {
|
||||
const fallbackFilePath = path.join(fallbackSrcDir, relativeFilePath);
|
||||
try {
|
||||
if (!nodeFs.existsSync(fallbackFilePath)) return acc;
|
||||
} catch { return acc; }
|
||||
} else {
|
||||
return acc;
|
||||
}
|
||||
}
|
||||
} catch { return acc; }
|
||||
}
|
||||
|
||||
acc.push(newItem);
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active navigation for a version - checks for navigation.json (Nav V2),
|
||||
* then per-version config override, then falls back to the global config navigation.
|
||||
*/
|
||||
export function resolveVersionNav(v: any, vSrcDir: string, configNavigation: any): any {
|
||||
let activeNav = configNavigation;
|
||||
|
||||
try {
|
||||
const navJsonPath = path.join(vSrcDir, 'navigation.json');
|
||||
if (nodeFs.existsSync(navJsonPath)) {
|
||||
const rawNav = nodeFs.readFileSync(navJsonPath, 'utf-8');
|
||||
activeNav = JSON.parse(rawNav);
|
||||
} else if (v.navigation) {
|
||||
activeNav = v.navigation;
|
||||
}
|
||||
} catch (err) {
|
||||
TUI.warn(`Failed to parse navigation.json in ${vSrcDir}: ${err.message}`);
|
||||
activeNav = v.navigation || configNavigation;
|
||||
}
|
||||
|
||||
// Clone activeNav before mutating to avoid modifying global config
|
||||
activeNav = JSON.parse(JSON.stringify(activeNav));
|
||||
normalizeNavPaths(activeNav);
|
||||
|
||||
return activeNav;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build all versions for a given base config and output directory.
|
||||
* Returns pages array with output paths prefixed for non-current versions.
|
||||
*/
|
||||
export async function buildVersions({
|
||||
config,
|
||||
outputDir,
|
||||
hooks,
|
||||
buildHash,
|
||||
options,
|
||||
CWD,
|
||||
pathPrefix = '',
|
||||
onProgress,
|
||||
targetFiles,
|
||||
coreVersion
|
||||
}: {
|
||||
config: any;
|
||||
outputDir: string;
|
||||
hooks: any;
|
||||
buildHash: string;
|
||||
options: any;
|
||||
CWD: string;
|
||||
pathPrefix?: string;
|
||||
onProgress?: (current: number, total: number) => void;
|
||||
targetFiles?: string[];
|
||||
coreVersion?: string;
|
||||
}): Promise<any[]> {
|
||||
const allPages = [];
|
||||
|
||||
// Pre-scan: determine which versions have i18n support for the current locale
|
||||
const versionI18nMap: Record<string, boolean> = {};
|
||||
for (const v of config.versions.all) {
|
||||
const baseSrcDir = path.resolve(CWD, v.dir);
|
||||
const localeSrcDir = resolveLocaleSrcDir(baseSrcDir, config);
|
||||
versionI18nMap[v.id] = await fs.exists(localeSrcDir);
|
||||
}
|
||||
config._versionI18nMap = versionI18nMap;
|
||||
|
||||
for (const v of config.versions.all) {
|
||||
const isCurrent = v.id === config.versions.current;
|
||||
const baseSrcDir = path.resolve(CWD, v.dir);
|
||||
|
||||
// When i18n is enabled, resolve to the locale-specific subdirectory
|
||||
// e.g., docs/ → docs/en/ for English locale
|
||||
// Graceful fallback: if the locale subdir doesn't exist but the base dir does,
|
||||
// use the base dir directly (supports old versions without locale dirs)
|
||||
let vSrcDir = resolveLocaleSrcDir(baseSrcDir, config);
|
||||
let fallbackSrcDir = resolveFallbackSrcDir(baseSrcDir, config);
|
||||
let versionHasI18n = true;
|
||||
|
||||
if (!await fs.exists(vSrcDir) && await fs.exists(baseSrcDir)) {
|
||||
// Locale subdir doesn't exist but base dir does - this version has no i18n structure
|
||||
versionHasI18n = false;
|
||||
if (config._activeLocale && config._activeLocale.id !== config._defaultLocale) {
|
||||
// Non-default locale: skip entirely (no translations for this version)
|
||||
continue;
|
||||
}
|
||||
// Default locale: use the base dir directly (backward compat for old versions)
|
||||
vSrcDir = baseSrcDir;
|
||||
fallbackSrcDir = null;
|
||||
}
|
||||
|
||||
if (!await fs.exists(vSrcDir)) {
|
||||
if (!options.isDev) TUI.warn(`Version directory missing: ${v.dir}. Skipping ${v.id}...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine the full output prefix for this version combining locale prefix + version prefix
|
||||
// Only non-current versions get a version prefix.
|
||||
const versionPrefixSegment = isCurrent ? '' : v.id + '/';
|
||||
const combinedOutputPrefix = pathPrefix + versionPrefixSegment;
|
||||
|
||||
const activeNav = resolveVersionNav(v, vSrcDir, config.navigation);
|
||||
const cleanedNav = filterNavForVersion(activeNav, vSrcDir, fallbackSrcDir);
|
||||
|
||||
const versionedConfig = {
|
||||
...config,
|
||||
_activeVersion: v,
|
||||
_versionHasI18n: versionHasI18n,
|
||||
navigation: cleanedNav
|
||||
};
|
||||
|
||||
const pages = await renderPages({
|
||||
config: versionedConfig,
|
||||
srcDir: vSrcDir,
|
||||
fallbackSrcDir,
|
||||
outputDir, // We always pass root outputDir so relativePathToRoot computes perfectly
|
||||
hooks,
|
||||
buildHash,
|
||||
options,
|
||||
outputPrefix: combinedOutputPrefix,
|
||||
onProgress,
|
||||
targetFiles,
|
||||
coreVersion
|
||||
});
|
||||
|
||||
allPages.push(...pages);
|
||||
}
|
||||
|
||||
return allPages;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* @package @docmd/core
|
||||
* @website https://docmd.io
|
||||
* @repository https://github.com/docmd-io/docmd
|
||||
* @license MIT
|
||||
* @copyright Copyright (c) 2025-present docmd.io
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Worker-thread parser
|
||||
* ====================
|
||||
*
|
||||
* Each docmd worker thread runs an independent copy of this module —
|
||||
* Node.js `worker_threads` load modules per worker, so `mdProcessor`,
|
||||
* `hooks`, and `config` below are PER-WORKER state, not shared across
|
||||
* threads. Phase 2 (F1–F5) requires that two workers given the same
|
||||
* input produce byte-identical HTML; the determinism contract is
|
||||
* enforced by:
|
||||
*
|
||||
* 1. `mdProcessor` is instantiated inside `init()` on this worker,
|
||||
* never imported from the main thread.
|
||||
* 2. `hooks` are loaded on this worker via `loadPlugins(config, ...)`.
|
||||
* No plugin module is cached across workers.
|
||||
* 3. `config` is the structured-clone snapshot from `workerData` —
|
||||
* no live reference to the main-thread config object.
|
||||
* 4. The container normaliser (`@docmd/parser/utils/container-normaliser`)
|
||||
* is a pure function of its input — no `Date.now()`, no
|
||||
* `Math.random()`, no module-level mutable state. The determinism
|
||||
* test fixture at `packages/parser/test/container-normaliser.test.js`
|
||||
* verifies this empirically (100-way concurrency + cross-worker).
|
||||
* 5. `verifyDeterminismAtBoot()` runs a known input through the
|
||||
* freshly-built processor at the end of `init()` and asserts the
|
||||
* output matches the snapshot. Any future regression that breaks
|
||||
* determinism crashes the worker before the first message is
|
||||
* processed.
|
||||
*
|
||||
* If you add any module-level mutable state to this file or to the
|
||||
* parser pipeline, the boot-time self-test will fail and the worker
|
||||
* will refuse to start. That is the structural fence.
|
||||
*/
|
||||
|
||||
import { parentPort, workerData } from 'node:worker_threads';
|
||||
import { loadPlugins } from '@docmd/api';
|
||||
import { createMarkdownProcessor, processContentAsync } from '@docmd/parser';
|
||||
import * as ui from '@docmd/ui';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Per-worker state — `let` is intentional; these are populated by
|
||||
// `init()` and frozen-equivalent after that point.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
let mdProcessor: any;
|
||||
let hooks: any;
|
||||
let config: any;
|
||||
|
||||
/**
|
||||
* Determinism snapshot. Two workers given the same input MUST produce
|
||||
* the same HTML. We assert this at boot by parsing a known input and
|
||||
* comparing the output to this frozen string. Any future code change
|
||||
* that introduces non-determinism (Date.now, Math.random, shared
|
||||
* module-level state, etc.) crashes the worker before it processes
|
||||
* its first real message.
|
||||
*
|
||||
* The snapshot is intentionally a small, isolated example — a balanced
|
||||
* callout with a self-closing tag — that exercises the normaliser
|
||||
* (no warnings expected), the depth tracker, the renderer pipeline,
|
||||
* and the heading ID plugin.
|
||||
*/
|
||||
const DETERMINISM_SNAPSHOT_INPUT = '# Hi\n\n::: callout info "T"\nbody\n:::\n';
|
||||
const DETERMINISM_SNAPSHOT_OUTPUT = '<h1 id="hi" class="docmd-heading"><a href="#hi" class="heading-anchor" aria-label="Permalink to this section"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-link2-icon lucide-link-2"><path d="M9 17H7A5 5 0 0 1 7 7h2m6 0h2a5 5 0 1 1 0 10h-2m-7-5h8"/></svg></a>Hi</h1>\n<div class="docmd-container callout callout-info"><div class="callout-title">T</div><div class="callout-content">\n<p>body</p>\n</div></div>\n';
|
||||
|
||||
async function init() {
|
||||
config = workerData.config;
|
||||
const cwd = workerData.cwd;
|
||||
|
||||
// 1. Re-hydrate hooks by loading plugins within the worker boundary.
|
||||
hooks = await loadPlugins(config, { resolvePaths: [cwd] });
|
||||
|
||||
// 2. Re-hydrate UI strings for the markdown processor (for heading anchors, etc.)
|
||||
const localeId = config._activeLocale?.id || null;
|
||||
const pluginTranslations = hooks.translations
|
||||
? await hooks.translations.reduce(async (accP: any, fn: any) => ({ ...(await accP), ...(await fn(localeId)) }), {})
|
||||
: {};
|
||||
const userLocaleTranslations = config._activeLocale?.translations || {};
|
||||
const strings = ui.loadTranslations(localeId, { ...pluginTranslations, ...userLocaleTranslations });
|
||||
const configWithStrings = { ...config, _uiStrings: strings };
|
||||
|
||||
// 3. Instantiate the exact same Markdown-It pipeline as the main thread
|
||||
mdProcessor = createMarkdownProcessor(configWithStrings, (md: any) => {
|
||||
hooks.markdownSetup.forEach((hook: any) => hook(md));
|
||||
});
|
||||
|
||||
// 4. Boot-time determinism self-test. Crashes the worker if the
|
||||
// parser output diverges from the snapshot, which means the
|
||||
// parser is no longer deterministic and parallel workers may
|
||||
// produce different HTML for the same input.
|
||||
await verifyDeterminismAtBoot();
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot-time determinism check. Parses `DETERMINISM_SNAPSHOT_INPUT`
|
||||
* through the freshly-built processor and asserts the output equals
|
||||
* `DETERMINISM_SNAPSHOT_OUTPUT`. If it doesn't, this is a regression
|
||||
* — the worker crashes with a clear error message before processing
|
||||
* any real message.
|
||||
*/
|
||||
async function verifyDeterminismAtBoot(): Promise<void> {
|
||||
const result = await processContentAsync(
|
||||
DETERMINISM_SNAPSHOT_INPUT,
|
||||
mdProcessor,
|
||||
config,
|
||||
{ filePath: '<determinism-snapshot>' },
|
||||
hooks
|
||||
);
|
||||
if (result.htmlContent !== DETERMINISM_SNAPSHOT_OUTPUT) {
|
||||
throw new Error(
|
||||
'[docmd] Parser determinism regression detected at worker boot.\n' +
|
||||
' Expected:\n' + JSON.stringify(DETERMINISM_SNAPSHOT_OUTPUT) + '\n' +
|
||||
' Actual:\n' + JSON.stringify(result.htmlContent) + '\n' +
|
||||
' This means two worker threads given the same input may now\n' +
|
||||
' produce different HTML — the parser pipeline is no longer\n' +
|
||||
' deterministic. Audit `packages/parser/src/` for any new\n' +
|
||||
' module-level mutable state, Date.now, Math.random, or\n' +
|
||||
' non-pure helpers.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Start initialization immediately upon worker thread spawn
|
||||
const initPromise = init();
|
||||
|
||||
parentPort?.on('message', async (task) => {
|
||||
try {
|
||||
// Ensure the worker is fully initialized before processing any task
|
||||
await initPromise;
|
||||
|
||||
// Support generic tasks for plugins leveraging the WorkerPool
|
||||
if (task.payload && task.payload.type === 'plugin-task') {
|
||||
const { modulePath, functionName, args } = task.payload;
|
||||
const mod = await import(modulePath);
|
||||
const result = await mod[functionName](...args);
|
||||
parentPort?.postMessage({
|
||||
taskId: task.id,
|
||||
success: true,
|
||||
data: result
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { rawContent, env } = task.payload;
|
||||
|
||||
// Process the content (includes frontmatter extraction, hooks execution, and HTML rendering)
|
||||
const processed = await processContentAsync(rawContent, mdProcessor, config, env, hooks);
|
||||
|
||||
parentPort?.postMessage({
|
||||
taskId: task.id,
|
||||
success: true,
|
||||
data: processed
|
||||
});
|
||||
} catch (error: any) {
|
||||
parentPort?.postMessage({
|
||||
taskId: task.id,
|
||||
success: false,
|
||||
error: error.message || String(error)
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,768 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* @package @docmd/core (and ecosystem)
|
||||
* @website https://docmd.io
|
||||
* @repository https://github.com/docmd-io/docmd
|
||||
* @license MIT
|
||||
* @copyright Copyright (c) 2025-present docmd.io
|
||||
*
|
||||
* [docmd-source] - Please do not remove this header.
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Workspace Handler.
|
||||
*
|
||||
* Enables a single docmd instance to build multiple independent
|
||||
* documentation projects under one domain.
|
||||
*
|
||||
* Root config:
|
||||
* workspace: {
|
||||
* projects: [
|
||||
* { prefix: '/', src: 'docmd-main' },
|
||||
* { prefix: '/search', src: 'docmd-search' }
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
* Each project folder has its own docmd.config.json with its own
|
||||
* title, versions, i18n, plugins, navigation, etc.
|
||||
*
|
||||
* Output merges into a single site/ directory:
|
||||
* site/ ← root project
|
||||
* site/search/ ← prefixed project
|
||||
*
|
||||
* Assets:
|
||||
* - Root-level assets/ are shared across all projects
|
||||
* - Each project can have its own assets/ that override shared ones
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { fsUtils as fs, FileSignatureTracker } from '@docmd/utils';
|
||||
import nativeFs from 'fs';
|
||||
import { TUI } from '@docmd/tui';
|
||||
import { loadConfig } from '../utils/config-loader.js';
|
||||
import { buildSite } from '../commands/build.js';
|
||||
import { createOriginVerify } from '../utils/ws-origin-guard.js';
|
||||
|
||||
/* ── Types ─────────────────────────────────────────────────── */
|
||||
|
||||
export interface ProjectEntry {
|
||||
/** URL prefix. '/' for root, '/search' for subpath. */
|
||||
prefix: string;
|
||||
/** Source directory relative to CWD (contains the project's docmd.config.js). */
|
||||
src: string;
|
||||
/** Optional: Display title for the project switcher. */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceConfig {
|
||||
/** The list of projects in the workspace. */
|
||||
projects: ProjectEntry[];
|
||||
/** Project switcher configuration. */
|
||||
switcher?: {
|
||||
enabled?: boolean;
|
||||
position?: 'sidebar-top' | 'sidebar-bottom' | 'options-menu';
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkspaceRootConfig {
|
||||
/** The workspace configuration. */
|
||||
workspace?: WorkspaceConfig;
|
||||
/** Legacy projects array (deprecated, maps to workspace.projects). */
|
||||
projects?: ProjectEntry[];
|
||||
/** Shared output directory. Default: 'site' */
|
||||
out?: string;
|
||||
/** Internal: Absolute path to the config file itself. Used for hot-reloading. */
|
||||
_resolvedPath?: string;
|
||||
/** Internal: Any other root keys act as global defaults. */
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/* ── Detection ─────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Check if a raw config object is a workspace config.
|
||||
*/
|
||||
export function isWorkspace(rawConfig: any): rawConfig is WorkspaceRootConfig {
|
||||
if (!rawConfig) return false;
|
||||
|
||||
// New 'workspace' schema
|
||||
if (rawConfig.workspace && Array.isArray(rawConfig.workspace.projects)) {
|
||||
return rawConfig.workspace.projects.every((p: any) =>
|
||||
typeof p.prefix === 'string' && typeof p.src === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
// Legacy 'projects' schema (for backward compatibility)
|
||||
if (Array.isArray(rawConfig.projects) && rawConfig.projects.length > 0) {
|
||||
return rawConfig.projects.every((p: any) =>
|
||||
typeof p.prefix === 'string' && typeof p.src === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the raw config file without normalization to check for workspace settings.
|
||||
*/
|
||||
export async function detectWorkspace(configPathOption: string): Promise<WorkspaceRootConfig | null> {
|
||||
const CWD = process.cwd();
|
||||
let absolutePath = path.resolve(CWD, configPathOption);
|
||||
|
||||
if (configPathOption === 'docmd.config.js') {
|
||||
const candidates = [
|
||||
'docmd.config.json',
|
||||
'docmd.config.ts',
|
||||
'docmd.config.js',
|
||||
'docmd.config.mjs',
|
||||
'config.js'
|
||||
];
|
||||
let found = false;
|
||||
for (const c of candidates) {
|
||||
const p = path.resolve(CWD, c);
|
||||
if (nativeFs.existsSync(p)) {
|
||||
absolutePath = p;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) return null;
|
||||
} else if (!nativeFs.existsSync(absolutePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
let rawConfig: any;
|
||||
|
||||
if (absolutePath.endsWith('.json')) {
|
||||
rawConfig = JSON.parse(nativeFs.readFileSync(absolutePath, 'utf-8'));
|
||||
} else {
|
||||
// Polyfill defineConfig
|
||||
(global as any).defineConfig = (config: any) => config;
|
||||
|
||||
const ts = Date.now();
|
||||
const ext = path.extname(absolutePath);
|
||||
const tempPath = absolutePath.replace(new RegExp(`\\${ext}$`), `-${ts}${ext}`);
|
||||
nativeFs.copyFileSync(absolutePath, tempPath);
|
||||
|
||||
const { pathToFileURL } = await import('url');
|
||||
const configUrl = pathToFileURL(tempPath).href;
|
||||
const rawModule = await import(configUrl);
|
||||
rawConfig = rawModule.default || rawModule;
|
||||
|
||||
nativeFs.unlinkSync(tempPath);
|
||||
delete (global as any).defineConfig;
|
||||
}
|
||||
|
||||
if (isWorkspace(rawConfig)) {
|
||||
rawConfig._resolvedPath = absolutePath;
|
||||
|
||||
// Internal normalization: map legacy 'projects' to 'workspace.projects'
|
||||
if (!rawConfig.workspace && rawConfig.projects) {
|
||||
rawConfig.workspace = {
|
||||
projects: rawConfig.projects,
|
||||
switcher: { enabled: true, position: 'sidebar-top' }
|
||||
};
|
||||
} else if (rawConfig.workspace) {
|
||||
// Ensure default switcher settings
|
||||
rawConfig.workspace.switcher = {
|
||||
enabled: true,
|
||||
position: 'sidebar-top',
|
||||
...rawConfig.workspace.switcher
|
||||
};
|
||||
}
|
||||
|
||||
// Return workspace config only if it has workspace settings
|
||||
// This prevents false positives when a config has a 'workspace' field but no projects
|
||||
if (rawConfig.workspace && rawConfig.workspace.projects) {
|
||||
return rawConfig as WorkspaceRootConfig;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
delete (global as any).defineConfig;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Validation ────────────────────────────────────────────── */
|
||||
|
||||
function validateProjects(projects: ProjectEntry[]): void {
|
||||
const prefixes = new Set<string>();
|
||||
let hasRoot = false;
|
||||
|
||||
for (const project of projects) {
|
||||
// Normalize prefix
|
||||
const prefix = project.prefix === '/' ? '/' : project.prefix.replace(/\/$/, '');
|
||||
|
||||
if (prefixes.has(prefix)) {
|
||||
throw new Error(`Duplicate project prefix: "${prefix}"`);
|
||||
}
|
||||
prefixes.add(prefix);
|
||||
|
||||
if (prefix === '/') hasRoot = true;
|
||||
|
||||
// Verify source directory exists
|
||||
const srcPath = path.resolve(process.cwd(), project.src);
|
||||
if (!nativeFs.existsSync(srcPath)) {
|
||||
throw new Error(`Project source directory not found: ${project.src}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasRoot) {
|
||||
throw new Error(
|
||||
'Workspace configuration must include a root project with prefix "/".\n' +
|
||||
'Each workspace needs one project that owns the site root, with the others mounted under their own prefixes.\n' +
|
||||
'Example:\n' +
|
||||
' {\n' +
|
||||
' "workspace": {\n' +
|
||||
' "projects": [\n' +
|
||||
' { "name": "main", "src": "./docs", "prefix": "/" },\n' +
|
||||
' { "name": "api", "src": "./api-docs", "prefix": "/api/" }\n' +
|
||||
' ]\n' +
|
||||
' }\n' +
|
||||
' }\n' +
|
||||
'See https://docs.docmd.io/configuration/workspaces/ for the full layout guide.'
|
||||
);
|
||||
}
|
||||
|
||||
// Check for potential conflicts
|
||||
const rootProject = projects.find(p => p.prefix === '/');
|
||||
if (rootProject) {
|
||||
const rootSrcPath = path.resolve(process.cwd(), rootProject.src);
|
||||
for (const project of projects) {
|
||||
if (project.prefix === '/') continue;
|
||||
const prefixName = project.prefix.replace(/^\//, '');
|
||||
const conflictPath = path.join(rootSrcPath, prefixName);
|
||||
if (nativeFs.existsSync(conflictPath)) {
|
||||
TUI.warn(`Potential conflict: Root project has "${prefixName}/" folder which may conflict with project prefix "${project.prefix}".`);
|
||||
TUI.warn(`Content at "${rootProject.src}/${prefixName}/" will be overwritten by project "${project.src}".`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Build ─────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Build all projects in a workspace.
|
||||
*/
|
||||
export async function buildWorkspace(
|
||||
workspaceConfig: WorkspaceRootConfig,
|
||||
opts: { isDev?: boolean; offline?: boolean; quiet?: boolean; verbose?: boolean } = {}
|
||||
): Promise<void> {
|
||||
const CWD = process.cwd();
|
||||
const rootOutDir = path.resolve(CWD, workspaceConfig.out || 'site');
|
||||
const totalElapsed = TUI.timer();
|
||||
|
||||
const workspace = workspaceConfig.workspace!;
|
||||
validateProjects(workspace.projects);
|
||||
|
||||
// Extract global defaults
|
||||
const globalDefaults: Record<string, any> = {};
|
||||
const skipKeys = ['workspace', 'projects', 'out', '_resolvedPath'];
|
||||
for (const key of Object.keys(workspaceConfig)) {
|
||||
if (!skipKeys.includes(key) && !key.startsWith('_')) {
|
||||
globalDefaults[key] = workspaceConfig[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: root project first, then alphabetical
|
||||
const sorted = [...workspace.projects].sort((a, b) => {
|
||||
if (a.prefix === '/') return -1;
|
||||
if (b.prefix === '/') return 1;
|
||||
return a.prefix.localeCompare(b.prefix);
|
||||
});
|
||||
|
||||
if (!opts.quiet) {
|
||||
TUI.section(`Workspace Build (${sorted.length} projects)`);
|
||||
}
|
||||
|
||||
// Ensure clean output directory
|
||||
await fs.ensureDir(rootOutDir);
|
||||
|
||||
// Copy shared assets first
|
||||
const sharedAssetsDir = path.resolve(CWD, 'assets');
|
||||
if (nativeFs.existsSync(sharedAssetsDir)) {
|
||||
if (!opts.quiet) {
|
||||
TUI.item('Shared assets', path.relative(CWD, sharedAssetsDir), TUI.dim, TUI.cyan);
|
||||
}
|
||||
}
|
||||
|
||||
if (!opts.quiet) {
|
||||
TUI.footer(TUI.cyan);
|
||||
}
|
||||
|
||||
for (const project of sorted) {
|
||||
const prefix = project.prefix === '/' ? '/' : project.prefix.replace(/\/$/, '');
|
||||
const projectSrcDir = path.resolve(CWD, project.src);
|
||||
|
||||
const candidates = ['docmd.config.json', 'docmd.config.ts', 'docmd.config.js', 'docmd.config.mjs', 'config.js'];
|
||||
let resolvedConfigName: string | null = null;
|
||||
for (const c of candidates) {
|
||||
if (nativeFs.existsSync(path.join(projectSrcDir, c))) {
|
||||
resolvedConfigName = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const hasProjectConfig = resolvedConfigName !== null;
|
||||
const configFileToUse = resolvedConfigName || 'docmd.config.js';
|
||||
|
||||
const projectOutDir = prefix === '/'
|
||||
? rootOutDir
|
||||
: path.join(rootOutDir, prefix.replace(/^\//, ''));
|
||||
|
||||
const label = prefix === '/' ? `/ (root)` : prefix;
|
||||
|
||||
if (!opts.quiet) {
|
||||
TUI.section(`Building Project: ${label}`);
|
||||
|
||||
const originalCwdCheck = process.cwd();
|
||||
process.chdir(projectSrcDir);
|
||||
try {
|
||||
const childConfig = await loadConfig(configFileToUse, { isDev: opts.isDev, quiet: true });
|
||||
const childDetails = TUI.extractProjectDetails(childConfig, projectOutDir, CWD);
|
||||
TUI.projectDetails({
|
||||
engine: childConfig.engine && childConfig.engine !== 'js' ? childConfig.engine : undefined,
|
||||
source: `${project.src}/`,
|
||||
output: `${path.relative(CWD, projectOutDir)}/`,
|
||||
versions: childDetails.versions,
|
||||
locales: childDetails.locales,
|
||||
});
|
||||
} catch {
|
||||
TUI.item('Source', `${project.src}/`, TUI.dim, TUI.cyan);
|
||||
TUI.item('Output', `${path.relative(CWD, projectOutDir)}/`, TUI.dim, TUI.cyan);
|
||||
} finally {
|
||||
process.chdir(originalCwdCheck);
|
||||
}
|
||||
|
||||
if (!hasProjectConfig) {
|
||||
TUI.item('Config', 'zero-config (no docmd.config.json found)', TUI.dim, TUI.cyan);
|
||||
}
|
||||
}
|
||||
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(projectSrcDir);
|
||||
|
||||
try {
|
||||
process.env.DOCMD_PROJECT_OUT = projectOutDir;
|
||||
process.env.DOCMD_PROJECT_PREFIX = prefix;
|
||||
|
||||
if (nativeFs.existsSync(sharedAssetsDir)) {
|
||||
await fs.ensureDir(path.join(projectOutDir, 'assets'));
|
||||
await fs.copy(sharedAssetsDir, path.join(projectOutDir, 'assets'));
|
||||
}
|
||||
|
||||
await buildSite(configFileToUse, {
|
||||
isDev: opts.isDev || false,
|
||||
offline: opts.offline || false,
|
||||
// Pass through quiet so the first build (quiet:false) shows git/search
|
||||
// indexing progress. Live-reload rebuilds always arrive with quiet:true
|
||||
// from buildWorkspaceProject, so they stay silent.
|
||||
quiet: opts.quiet || false,
|
||||
// Forward --verbose so the per-project normaliser prints every
|
||||
// container issue (unclosed ::: card, stray :::, etc.). Without
|
||||
// this, `docmd build --verbose` on a workspace config silently
|
||||
// drops the flag and only the aggregate error count is shown.
|
||||
verbose: opts.verbose || false,
|
||||
_globalDefaults: globalDefaults,
|
||||
_workspace: workspace,
|
||||
_activePrefix: prefix
|
||||
});
|
||||
|
||||
if (!opts.quiet) {
|
||||
TUI.footer(TUI.cyan);
|
||||
}
|
||||
|
||||
} catch (err: any) {
|
||||
if (!opts.quiet) {
|
||||
TUI.step(`Project ${label} build failed`, 'FAIL', TUI.cyan);
|
||||
TUI.footer(TUI.cyan);
|
||||
}
|
||||
TUI.error(`Build failed for ${label}`, err.message);
|
||||
if (!opts.isDev) process.exit(1);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
delete process.env.DOCMD_PROJECT_OUT;
|
||||
delete process.env.DOCMD_PROJECT_PREFIX;
|
||||
}
|
||||
}
|
||||
|
||||
if (!opts.quiet) {
|
||||
const totalSize = await getDirectorySize(rootOutDir);
|
||||
TUI.success(`Workspace build complete. ${sorted.length} projects → ${path.relative(CWD, rootOutDir)}/ (${formatBytes(totalSize)}) in ${totalElapsed()}.\n`);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Single Project Build ────────────────── */
|
||||
|
||||
async function buildWorkspaceProject(
|
||||
project: ProjectEntry,
|
||||
workspaceConfig: WorkspaceRootConfig,
|
||||
opts: { isDev?: boolean; offline?: boolean; quiet?: boolean; verbose?: boolean; targetFiles?: string[] } = {}
|
||||
): Promise<void> {
|
||||
const CWD = process.cwd();
|
||||
const rootOutDir = path.resolve(CWD, workspaceConfig.out || 'site');
|
||||
const workspace = workspaceConfig.workspace!;
|
||||
const prefix = project.prefix === '/' ? '/' : project.prefix.replace(/\/$/, '');
|
||||
const projectSrcDir = path.resolve(CWD, project.src);
|
||||
|
||||
const candidates = ['docmd.config.json', 'docmd.config.ts', 'docmd.config.js', 'docmd.config.mjs', 'config.js'];
|
||||
let resolvedConfigName: string | null = null;
|
||||
for (const c of candidates) {
|
||||
if (nativeFs.existsSync(path.join(projectSrcDir, c))) {
|
||||
resolvedConfigName = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const configFileToUse = resolvedConfigName || 'docmd.config.js';
|
||||
|
||||
const projectOutDir = prefix === '/'
|
||||
? rootOutDir
|
||||
: path.join(rootOutDir, prefix.replace(/^\//, ''));
|
||||
|
||||
const label = prefix === '/' ? '/ (root)' : prefix;
|
||||
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(projectSrcDir);
|
||||
|
||||
try {
|
||||
process.env.DOCMD_PROJECT_OUT = projectOutDir;
|
||||
process.env.DOCMD_PROJECT_PREFIX = prefix;
|
||||
|
||||
const sharedAssetsDir = path.resolve(CWD, 'assets');
|
||||
if (nativeFs.existsSync(sharedAssetsDir)) {
|
||||
await fs.ensureDir(path.join(projectOutDir, 'assets'));
|
||||
await fs.copy(sharedAssetsDir, path.join(projectOutDir, 'assets'));
|
||||
}
|
||||
|
||||
const globalDefaults: Record<string, any> = {};
|
||||
const skipKeys = ['workspace', 'projects', 'out', '_resolvedPath'];
|
||||
for (const key of Object.keys(workspaceConfig)) {
|
||||
if (!skipKeys.includes(key) && !key.startsWith('_')) {
|
||||
globalDefaults[key] = workspaceConfig[key];
|
||||
}
|
||||
}
|
||||
|
||||
await buildSite(configFileToUse, {
|
||||
isDev: opts.isDev || false,
|
||||
offline: opts.offline || false,
|
||||
quiet: true,
|
||||
// Forward --verbose to the per-project build so container
|
||||
// normaliser issues are listed per file in verbose mode.
|
||||
verbose: opts.verbose || false,
|
||||
targetFiles: opts.targetFiles,
|
||||
_globalDefaults: globalDefaults,
|
||||
_workspace: workspace,
|
||||
_activePrefix: prefix
|
||||
});
|
||||
|
||||
} catch (err: any) {
|
||||
TUI.error(`Build failed for ${label}`, err.message);
|
||||
throw err;
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
delete process.env.DOCMD_PROJECT_OUT;
|
||||
delete process.env.DOCMD_PROJECT_PREFIX;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Dev Server Wrapper ────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Start dev server for workspace mode.
|
||||
*/
|
||||
export async function devWorkspace(
|
||||
workspaceConfig: WorkspaceRootConfig,
|
||||
opts: { port?: string; preserve?: boolean } = {}
|
||||
): Promise<void> {
|
||||
try {
|
||||
await buildWorkspace(workspaceConfig, { isDev: true });
|
||||
} catch (err: any) {
|
||||
TUI.error('Initial build failed', err.message);
|
||||
}
|
||||
|
||||
const CWD = process.cwd();
|
||||
const rootOutDir = path.resolve(CWD, workspaceConfig.out || 'site');
|
||||
|
||||
const { serveStatic, findAvailablePort, formatPathForDisplay, getNetworkIp, openBrowser } = await import('../utils/dev-utils.js');
|
||||
const http = await import('http');
|
||||
const { WebSocketServer, WebSocket } = await import('ws');
|
||||
|
||||
const state = { outputDir: rootOutDir };
|
||||
const server = http.createServer((req: any, res: any) => serveStatic(req, res, state.outputDir));
|
||||
|
||||
const PORT = parseInt(opts.port || process.env.PORT || '3000', 10);
|
||||
const port = await findAvailablePort(PORT);
|
||||
|
||||
let wss: any;
|
||||
|
||||
function broadcastReload() {
|
||||
if (wss) {
|
||||
wss.clients.forEach((client: any) => {
|
||||
if (client.readyState === WebSocket.OPEN) client.send('reload');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 1.D: default to loopback. Set DOCMD_HOST=0.0.0.0 to expose on LAN
|
||||
// (with the verifyClient callback below still guarding the Origin header).
|
||||
const BIND_HOST = process.env.DOCMD_HOST || '127.0.0.1';
|
||||
|
||||
server.listen(port, BIND_HOST, () => {
|
||||
if (BIND_HOST !== '127.0.0.1' && BIND_HOST !== '::1' && BIND_HOST !== 'localhost') {
|
||||
TUI.warn(`Workspace dev server bound to ${BIND_HOST} (LAN). verifyClient guards Origin.`);
|
||||
}
|
||||
const networkIp = getNetworkIp();
|
||||
// Phase 1.D: CWE-1385 CSWSH fix (N-S1). Even when bound to LAN, the
|
||||
// verifyClient callback rejects any Origin that isn't loopback or the
|
||||
// LAN IP returned by getNetworkIp().
|
||||
wss = new WebSocketServer({
|
||||
server,
|
||||
verifyClient: createOriginVerify(networkIp ? [networkIp] : []),
|
||||
});
|
||||
wss.on('error', (e: any) => TUI.error('WebSocket Error', e.message));
|
||||
|
||||
const localUrl = `http://127.0.0.1:${port}`;
|
||||
const networkUrl = networkIp ? `http://${networkIp}:${port}` : null;
|
||||
|
||||
TUI.section('Workspace Dev Server Running', TUI.green);
|
||||
TUI.item('', '', TUI.dim, TUI.green);
|
||||
TUI.item('Local Access', localUrl, TUI.bold, TUI.green);
|
||||
if (networkUrl) {
|
||||
TUI.item('Network Access', networkUrl, TUI.bold, TUI.green);
|
||||
}
|
||||
TUI.item('Serving from', formatPathForDisplay(rootOutDir, CWD), TUI.dim, TUI.green);
|
||||
TUI.item('', '', TUI.dim, TUI.green);
|
||||
|
||||
const workspace = workspaceConfig.workspace!;
|
||||
for (const project of workspace.projects) {
|
||||
const pfx = project.prefix === '/' ? '/' : project.prefix;
|
||||
TUI.item('Project', localUrl + pfx, TUI.dim, TUI.green);
|
||||
}
|
||||
TUI.item('', '', TUI.dim, TUI.green);
|
||||
TUI.footer(TUI.green);
|
||||
|
||||
// Auto-launch localhost URL in default browser
|
||||
openBrowser(localUrl);
|
||||
});
|
||||
|
||||
let isRebuilding = false;
|
||||
let rebuildTimeout: any = null;
|
||||
let lastContentRebuildAt = 0;
|
||||
let lastConfigRebuildAt = 0;
|
||||
// Quiet windows after a rebuild to absorb phantom fs.watch events macOS
|
||||
// emits for newly-written nearby files. The primary defence is the
|
||||
// content-signature check (FileSignatureTracker) below; these are
|
||||
// secondary cushions.
|
||||
// - 1000ms after a content rebuild
|
||||
// - 2500ms after a full workspace config rebuild (cascades everywhere)
|
||||
const CONTENT_QUIET_MS = 1000;
|
||||
const CONFIG_QUIET_MS = 2500;
|
||||
// Tracks each watched file's mtime+size signature so phantom fs.watch
|
||||
// events that don't actually mutate the file are silently ignored.
|
||||
const signatureTracker = new FileSignatureTracker();
|
||||
|
||||
const workspace = workspaceConfig.workspace!;
|
||||
for (const project of workspace.projects) {
|
||||
const projectSrcDir = path.resolve(CWD, project.src);
|
||||
|
||||
if (!nativeFs.existsSync(projectSrcDir)) continue;
|
||||
|
||||
nativeFs.watch(projectSrcDir, { recursive: true }, (event, filename) => {
|
||||
if (!filename) return;
|
||||
|
||||
// Post-rebuild quiet period
|
||||
if (Date.now() - lastContentRebuildAt < CONTENT_QUIET_MS) return;
|
||||
if (Date.now() - lastConfigRebuildAt < CONFIG_QUIET_MS) return;
|
||||
|
||||
if (filename.includes('.git') || filename.includes('node_modules') ||
|
||||
filename.includes('.DS_Store') || filename.startsWith('.') ||
|
||||
filename.includes('docmd.config-') || filename.endsWith('.js.bak')) return;
|
||||
|
||||
// Exclude build output
|
||||
const resolvedOut = path.resolve(CWD, workspaceConfig.out || 'site');
|
||||
const filePath = path.resolve(projectSrcDir, filename);
|
||||
if (filePath.startsWith(resolvedOut + path.sep) || filePath === resolvedOut) return;
|
||||
|
||||
// Content-signature gate: ignore phantom events that don't actually
|
||||
// mutate the file (Spotlight reindex, iCloud sync, etc.).
|
||||
if (!signatureTracker.hasChanged(filePath)) return;
|
||||
|
||||
if (rebuildTimeout) clearTimeout(rebuildTimeout);
|
||||
rebuildTimeout = setTimeout(async () => {
|
||||
if (isRebuilding) return;
|
||||
isRebuilding = true;
|
||||
|
||||
const isConfigUpdate = (filename.includes('docmd.config') && !filename.includes('docmd.config-')) || filename.includes('navigation.json');
|
||||
const label = project.prefix === '/' ? '/' : project.prefix;
|
||||
const displayPath = filename.replace(/^[^/]+\//, '');
|
||||
const rebuildElapsed = TUI.timer();
|
||||
|
||||
if (isConfigUpdate) {
|
||||
TUI.step(`Reloading config and rebuilding [${label}]`, 'WAIT', TUI.blue, true);
|
||||
} else {
|
||||
TUI.step(`Rebuilding [${label}] ${displayPath}`, 'WAIT', TUI.blue, true);
|
||||
}
|
||||
|
||||
try {
|
||||
const fullChangedPath = path.resolve(projectSrcDir, filename);
|
||||
await buildWorkspaceProject(project, workspaceConfig, {
|
||||
isDev: true,
|
||||
targetFiles: isConfigUpdate ? undefined : [fullChangedPath]
|
||||
});
|
||||
if (isConfigUpdate) {
|
||||
lastConfigRebuildAt = Date.now();
|
||||
signatureTracker.reset();
|
||||
} else {
|
||||
lastContentRebuildAt = Date.now();
|
||||
}
|
||||
broadcastReload();
|
||||
TUI.step(`Rebuilt [${label}] ${isConfigUpdate ? 'with new config' : displayPath} in ${rebuildElapsed()}`, 'DONE', TUI.blue, true);
|
||||
} catch (err: any) {
|
||||
TUI.step(`Rebuild [${label}] ${displayPath}`, 'FAIL', TUI.blue, true);
|
||||
TUI.error('Rebuild failed', err.message);
|
||||
} finally {
|
||||
isRebuilding = false;
|
||||
}
|
||||
}, 600); // 600ms idle: rebuild only after user stops saving
|
||||
});
|
||||
}
|
||||
|
||||
if (workspaceConfig._resolvedPath && nativeFs.existsSync(workspaceConfig._resolvedPath)) {
|
||||
let rootConfigDebounce: any = null;
|
||||
// Watch the parent directory rather than the file directly — far more
|
||||
// stable on macOS, where direct-file fs.watch fires phantom events from
|
||||
// Spotlight / iCloud / Time Machine.
|
||||
const rootConfigPath = workspaceConfig._resolvedPath;
|
||||
const rootConfigDir = path.dirname(rootConfigPath);
|
||||
const rootConfigBaseName = path.basename(rootConfigPath);
|
||||
|
||||
nativeFs.watch(rootConfigDir, (event, filename) => {
|
||||
if (!filename) return;
|
||||
if (filename !== rootConfigBaseName) return;
|
||||
|
||||
// Post-rebuild quiet period
|
||||
if (Date.now() - lastConfigRebuildAt < CONFIG_QUIET_MS) return;
|
||||
|
||||
// Content-signature gate: ignore phantom events that don't actually
|
||||
// mutate the file.
|
||||
if (!signatureTracker.hasChanged(rootConfigPath)) return;
|
||||
|
||||
if (rootConfigDebounce) clearTimeout(rootConfigDebounce);
|
||||
rootConfigDebounce = setTimeout(async () => {
|
||||
rootConfigDebounce = null;
|
||||
if (isRebuilding) return;
|
||||
isRebuilding = true;
|
||||
|
||||
const rebuildElapsed = TUI.timer();
|
||||
TUI.step('Reloading workspace config...', 'WAIT', TUI.blue, true);
|
||||
|
||||
try {
|
||||
const { detectWorkspace } = await import('./workspace.js');
|
||||
const newWorkspaceConfig = await detectWorkspace(rootConfigBaseName);
|
||||
if (newWorkspaceConfig) {
|
||||
Object.assign(workspaceConfig, newWorkspaceConfig);
|
||||
await buildWorkspace(newWorkspaceConfig, { isDev: true, quiet: true });
|
||||
lastConfigRebuildAt = Date.now();
|
||||
signatureTracker.reset();
|
||||
broadcastReload();
|
||||
TUI.step(`Rebuilt entire workspace with new config in ${rebuildElapsed()}`, 'DONE', TUI.blue, true);
|
||||
}
|
||||
} catch (err: any) {
|
||||
TUI.step('Workspace Rebuild', 'FAIL', TUI.blue, true);
|
||||
TUI.error('Rebuild failed', err.message);
|
||||
} finally {
|
||||
isRebuilding = false;
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
|
||||
if (nativeFs.existsSync(path.resolve(CWD, 'assets'))) {
|
||||
const sharedAssetsDir = path.resolve(CWD, 'assets');
|
||||
nativeFs.watch(sharedAssetsDir, { recursive: true }, (event, filename) => {
|
||||
if (!filename) return;
|
||||
// Post-rebuild quiet period
|
||||
if (Date.now() - lastContentRebuildAt < CONTENT_QUIET_MS) return;
|
||||
if (Date.now() - lastConfigRebuildAt < CONFIG_QUIET_MS) return;
|
||||
|
||||
// Content-signature gate: ignore phantom events that don't actually
|
||||
// mutate the file.
|
||||
const filePath = path.resolve(sharedAssetsDir, filename);
|
||||
if (!signatureTracker.hasChanged(filePath)) return;
|
||||
|
||||
if (rebuildTimeout) clearTimeout(rebuildTimeout);
|
||||
rebuildTimeout = setTimeout(async () => {
|
||||
if (isRebuilding) return;
|
||||
isRebuilding = true;
|
||||
|
||||
const rebuildElapsed = TUI.timer();
|
||||
TUI.step('Shared assets changed — rebuilding all', 'WAIT', TUI.blue, true);
|
||||
|
||||
try {
|
||||
await buildWorkspace(workspaceConfig, { isDev: true, quiet: true });
|
||||
lastContentRebuildAt = Date.now();
|
||||
broadcastReload();
|
||||
TUI.step(`All projects rebuilt in ${rebuildElapsed()}`, 'DONE', TUI.blue, true);
|
||||
} catch (err: any) {
|
||||
TUI.step('Rebuild all projects', 'FAIL', TUI.blue, true);
|
||||
TUI.error('Rebuild failed', err.message);
|
||||
} finally {
|
||||
isRebuilding = false;
|
||||
}
|
||||
}, 600); // 600ms idle: rebuild only after user stops saving
|
||||
});
|
||||
}
|
||||
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdin.resume();
|
||||
process.stdin.on('data', (data) => {
|
||||
if (data[0] === 0x03) {
|
||||
process.emit('SIGINT' as any);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let isShuttingDown = false;
|
||||
process.on('SIGINT', () => {
|
||||
if (isShuttingDown) return;
|
||||
isShuttingDown = true;
|
||||
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
||||
TUI.success('Shutting down workspace dev server...\n');
|
||||
server.close();
|
||||
if (wss) wss.close();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Helpers ───────────────────────────────────────────────── */
|
||||
|
||||
async function getDirectorySize(dir: string): Promise<number> {
|
||||
let total = 0;
|
||||
try {
|
||||
const entries = await nativeFs.promises.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
total += await getDirectorySize(fullPath);
|
||||
} else {
|
||||
const stat = await nativeFs.promises.stat(fullPath);
|
||||
total += stat.size;
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return total;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
declare module '@docmd/parser';
|
||||
declare module '@docmd/plugin-installer';
|
||||
declare module '@docmd/themes';
|
||||
declare module '@docmd/ui';
|
||||
declare module 'ws';
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* @package @docmd/core (and ecosystem)
|
||||
* @website https://docmd.io
|
||||
* @repository https://github.com/docmd-io/docmd
|
||||
* @license MIT
|
||||
* @copyright Copyright (c) 2025-present docmd.io
|
||||
*
|
||||
* [docmd-source] - Please do not remove this header.
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export function defineConfig(config: any): any {
|
||||
return config;
|
||||
}
|
||||
|
||||
export { buildSite as build } from './commands/build.js';
|
||||
export { startDevServer as dev } from './commands/dev.js';
|
||||
export { buildLive } from './commands/live.js';
|
||||
|
||||
// D-H2: re-export the workspace helpers under their documented names.
|
||||
// The skill/docs reference `buildWorkspace`, `detectWorkspace`, and
|
||||
// `isWorkspace` directly; previously only `build` (= buildSite) was
|
||||
// exposed from the package root and consumers got `SyntaxError:
|
||||
// does not provide an export named 'buildWorkspace'` at import time.
|
||||
export { buildWorkspace, detectWorkspace, isWorkspace } from './engine/workspace.js';
|
||||
|
||||
// Re-export from @docmd/api for backward compatibility
|
||||
// These modules have moved to @docmd/api as of 0.7.1.
|
||||
// Direct imports from @docmd/core continue to work but consumers
|
||||
// are encouraged to migrate to @docmd/api.
|
||||
export { createActionDispatcher, safePath, createSourceTools } from '@docmd/api';
|
||||
|
||||
// Plugin API types (re-exported from @docmd/api)
|
||||
export type {
|
||||
ActionContext,
|
||||
ActionHandler,
|
||||
EventHandler,
|
||||
DispatchResult,
|
||||
PluginModule,
|
||||
PluginDescriptor,
|
||||
PluginHooks,
|
||||
Capability,
|
||||
SourceTools,
|
||||
BlockInfo,
|
||||
InlineSegment,
|
||||
TextLocation,
|
||||
} from '@docmd/api';
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* @package @docmd/core (and ecosystem)
|
||||
* @website https://docmd.io
|
||||
* @repository https://github.com/docmd-io/docmd
|
||||
* @license MIT
|
||||
* @copyright Copyright (c) 2025-present docmd.io
|
||||
*
|
||||
* [docmd-source] - Please do not remove this header.
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { normalizeInternalHref } from '@docmd/parser';
|
||||
|
||||
/**
|
||||
* Convert a file/folder name to a URL-safe slug.
|
||||
* Replaces spaces and URL-unsafe characters with hyphens so that the
|
||||
* generated nav link, the output file path, and the browser URL all agree.
|
||||
*
|
||||
* Examples:
|
||||
* "folder with space" → "folder-with-space"
|
||||
* "my file (draft)" → "my-file-draft"
|
||||
* "already-slug_ok" → "already-slug_ok" (no change)
|
||||
*/
|
||||
function slugifySegment(name: string): string {
|
||||
return name
|
||||
.replace(/\s+/g, '-') // spaces → hyphens
|
||||
.replace(/[^a-zA-Z0-9\-_.~]/g, '-') // unsafe URL chars → hyphens
|
||||
.replace(/-{2,}/g, '-') // collapse consecutive hyphens
|
||||
.replace(/^-+|-+$/g, '') // strip leading/trailing hyphens
|
||||
|| name; // fallback: keep original if result is empty
|
||||
}
|
||||
|
||||
// Extract title from Frontmatter or H1 without loading heavy parsers
|
||||
function extractTitleFromFile(filePath: string, filename: string) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
// 1. Try YAML frontmatter title
|
||||
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (fmMatch) {
|
||||
const titleMatch = fmMatch[1].match(/^title:\s*"?([^"\n]+)"?/m);
|
||||
if (titleMatch) return titleMatch[1].trim();
|
||||
}
|
||||
|
||||
// 2. Try H1
|
||||
const h1Match = content.match(/^#\s+(.*)/m);
|
||||
if (h1Match) return h1Match[1].trim();
|
||||
|
||||
} catch { /* ignore parser errors */ }
|
||||
|
||||
// 3. Fallback: Prettify Filename
|
||||
// "index copy.md" -> "Index Copy"
|
||||
const cleanName = filename.replace(/\.(md|ejs)$/i, '');
|
||||
// Capitalize first letter of each word
|
||||
return cleanName.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively builds the navigation array for Zero Config mode.
|
||||
*/
|
||||
export function buildAutoNav(dir: string, basePath = '/'): any[] { // Default base path is root '/'
|
||||
const items = fs.readdirSync(dir, { withFileTypes: true });
|
||||
const nav: any[] = [];
|
||||
|
||||
// Handle index.md/index.ejs (or README.md if no index.md) -> maps to the folder root
|
||||
const hasIndex = items.some(i => /^index\.(md|ejs)$/i.test(i.name));
|
||||
const hasReadme = items.some(i => i.name.toLowerCase() === 'readme.md');
|
||||
const indexExists = hasIndex || hasReadme;
|
||||
|
||||
for (const item of items) {
|
||||
// Skip hidden files, node_modules, and typical output/asset dirs
|
||||
if (
|
||||
item.name.startsWith('.') ||
|
||||
item.name === '_playground' ||
|
||||
item.name === 'node_modules' ||
|
||||
item.name === 'assets' ||
|
||||
item.name === 'site' ||
|
||||
item.name === 'dist' ||
|
||||
item.name === 'bin' ||
|
||||
item.name === 'src' ||
|
||||
item.name === 'lib' ||
|
||||
item.name === 'test' ||
|
||||
item.name === 'tests' ||
|
||||
item.name === 'coverage' ||
|
||||
item.name === 'scripts' ||
|
||||
item.name === 'temp' ||
|
||||
item.name === 'tmp' ||
|
||||
item.name === 'build' ||
|
||||
item.name === 'vendor'
|
||||
) continue;
|
||||
|
||||
const fullPath = path.join(dir, item.name);
|
||||
|
||||
// Construct URL path: basePath + slugified filename.
|
||||
// Slugify so that spaces and URL-unsafe characters are replaced with hyphens,
|
||||
// keeping the nav link, output file path, and browser URL consistent.
|
||||
const safeBase = basePath.endsWith('/') ? basePath : basePath + '/';
|
||||
const relPath = safeBase + slugifySegment(item.name);
|
||||
|
||||
if (item.isDirectory()) {
|
||||
const children = buildAutoNav(fullPath, relPath);
|
||||
if (children.length > 0) {
|
||||
const title = item.name.replace(/[-_]/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
nav.push({
|
||||
title,
|
||||
collapsible: true,
|
||||
children
|
||||
});
|
||||
}
|
||||
} else if (item.isFile() && /\.(md|ejs)$/i.test(item.name)) {
|
||||
const title = extractTitleFromFile(fullPath, item.name);
|
||||
|
||||
let linkPath = relPath;
|
||||
|
||||
const isIndex = /^index\.(md|ejs)$/i.test(item.name);
|
||||
const isReadme = item.name.toLowerCase() === 'readme.md';
|
||||
|
||||
if (isIndex || (isReadme && !hasIndex)) {
|
||||
linkPath = basePath === '/' ? '/' : basePath;
|
||||
} else {
|
||||
// Use centralised normaliser for clean URLs with trailing slash
|
||||
linkPath = normalizeInternalHref(linkPath);
|
||||
}
|
||||
|
||||
nav.push({ title, path: linkPath });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: Put index.md (Home) at the top, then sort alphabetically
|
||||
return nav.sort((a, b) => {
|
||||
// Check if path effectively points to current folder root
|
||||
const aIsRoot = a.path === basePath || a.path === basePath + '/';
|
||||
const bIsRoot = b.path === basePath || b.path === basePath + '/';
|
||||
|
||||
if (aIsRoot && !bIsRoot) return -1;
|
||||
if (!aIsRoot && bIsRoot) return 1;
|
||||
|
||||
// Folders usually come after files in some docs, or before.
|
||||
// Let's standard: Files (Home) -> Folders -> Other Files
|
||||
const aIsDir = !!a.children;
|
||||
const bIsDir = !!b.children;
|
||||
|
||||
if (aIsDir && !bIsDir) return 1;
|
||||
if (!aIsDir && bIsDir) return -1;
|
||||
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* @package @docmd/core (and ecosystem)
|
||||
* @website https://docmd.io
|
||||
* @repository https://github.com/docmd-io/docmd
|
||||
* @license MIT
|
||||
* @copyright Copyright (c) 2025-present docmd.io
|
||||
*
|
||||
* [docmd-source] - Please do not remove this header.
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { validateConfig } from '@docmd/parser';
|
||||
import { normalizeConfig } from './config-schema.js';
|
||||
import { buildAutoNav } from './auto-router.js';
|
||||
import { pathToFileURL } from 'url';
|
||||
import { TUI } from '@docmd/api';
|
||||
|
||||
function hasMarkdownFiles(dir: string, maxDepth = 2, currentDepth = 0): boolean {
|
||||
if (currentDepth > maxDepth) return false;
|
||||
try {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.name === 'node_modules' || entry.name.startsWith('.') || entry.name === 'site' || entry.name === 'dist' || entry.name === 'out') continue;
|
||||
if (entry.isFile() && (entry.name.endsWith('.md') || entry.name.endsWith('.markdown') || entry.name.endsWith('.ejs'))) return true;
|
||||
if (entry.isDirectory()) {
|
||||
if (hasMarkdownFiles(path.join(dir, entry.name), maxDepth, currentDepth + 1)) return true;
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
function detectHeuristics(cwd: string, srcDir: string) {
|
||||
const absSrcDir = path.join(cwd, srcDir);
|
||||
let items: any[] = [];
|
||||
try {
|
||||
items = fs.readdirSync(absSrcDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return { versions: null, i18n: null };
|
||||
}
|
||||
|
||||
const versionFolders: string[] = [];
|
||||
const localeFolders: string[] = [];
|
||||
|
||||
const langMap: Record<string, string> = {
|
||||
en: 'English',
|
||||
de: 'Deutsch',
|
||||
zh: '简体中文',
|
||||
fr: 'Français',
|
||||
ja: '日本語',
|
||||
es: 'Español',
|
||||
ru: 'Русский',
|
||||
it: 'Italiano',
|
||||
pt: 'Português'
|
||||
};
|
||||
|
||||
const isVersion = (name: string) => /^v\d/i.test(name);
|
||||
const isLocale = (name: string) => /^[a-z]{2}(-[A-Z]{2})?$/i.test(name);
|
||||
|
||||
for (const item of items) {
|
||||
if (!item.isDirectory()) continue;
|
||||
if (item.name.startsWith('.') || item.name === 'node_modules') continue;
|
||||
|
||||
if (isVersion(item.name)) {
|
||||
versionFolders.push(item.name);
|
||||
} else if (isLocale(item.name)) {
|
||||
localeFolders.push(item.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort versions descending (v0.8, v0.7, etc.)
|
||||
versionFolders.sort((a, b) => b.localeCompare(a, undefined, { numeric: true, sensitivity: 'base' }));
|
||||
|
||||
let finalVersions: any = null;
|
||||
let finalI18n: any = null;
|
||||
|
||||
if (versionFolders.length > 0) {
|
||||
finalVersions = {
|
||||
all: versionFolders.map(v => ({
|
||||
id: v,
|
||||
dir: path.join(srcDir, v),
|
||||
label: v
|
||||
})),
|
||||
current: versionFolders[0]
|
||||
};
|
||||
|
||||
// Check if the current/first version contains locales
|
||||
const currentVerDir = path.join(absSrcDir, versionFolders[0]);
|
||||
if (fs.existsSync(currentVerDir)) {
|
||||
try {
|
||||
const verItems = fs.readdirSync(currentVerDir, { withFileTypes: true });
|
||||
const verLocales = verItems
|
||||
.filter(item => item.isDirectory() && isLocale(item.name))
|
||||
.map(item => item.name);
|
||||
|
||||
if (verLocales.length > 0) {
|
||||
const defaultLoc = verLocales.includes('en') ? 'en' : verLocales[0];
|
||||
finalI18n = {
|
||||
default: defaultLoc,
|
||||
locales: verLocales.map(loc => ({
|
||||
id: loc,
|
||||
label: langMap[loc.toLowerCase()] || loc.toUpperCase()
|
||||
}))
|
||||
};
|
||||
}
|
||||
} catch { /* ignore read errors */ }
|
||||
}
|
||||
} else if (localeFolders.length > 0) {
|
||||
const defaultLoc = localeFolders.includes('en') ? 'en' : localeFolders[0];
|
||||
finalI18n = {
|
||||
default: defaultLoc,
|
||||
locales: localeFolders.map(loc => ({
|
||||
id: loc,
|
||||
label: langMap[loc.toLowerCase()] || loc.toUpperCase()
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
return { versions: finalVersions, i18n: finalI18n };
|
||||
}
|
||||
|
||||
async function buildZeroConfig(cwd: string, isDev = false, quiet = false, options: any = {}) {
|
||||
|
||||
if (isDev && !quiet) {
|
||||
if (!(global as any).__DOCMD_ZERO_LOGGED) {
|
||||
TUI.info('Zero-Config mode activated. Analyzing directory...');
|
||||
(global as any).__DOCMD_ZERO_LOGGED = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Detect if there's a specific docs folder, otherwise use root
|
||||
const candidates = ['docs', 'src/docs', 'documentation', 'content'];
|
||||
let srcDir: string | null = null;
|
||||
for (const c of candidates) {
|
||||
if (fs.existsSync(path.join(cwd, c)) && hasMarkdownFiles(path.join(cwd, c), 2)) {
|
||||
srcDir = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!srcDir) {
|
||||
// Check if the root directory itself has markdown files
|
||||
if (hasMarkdownFiles(cwd, 1)) {
|
||||
srcDir = '.';
|
||||
}
|
||||
}
|
||||
|
||||
if (!srcDir) {
|
||||
TUI.error('Configuration Error', 'No documentation directory or files found.');
|
||||
TUI.info(`Zero-Config expects one of: ${candidates.join(', ')} or markdown files in the project root.`);
|
||||
TUI.info('Create one of these folders, place markdown files in the root, or provide a docmd.config.json file.');
|
||||
|
||||
const err: any = new Error('No candidate documentation directory or files found.');
|
||||
err.silent = true;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const absSrcDir = path.join(cwd, srcDir);
|
||||
|
||||
if (!hasMarkdownFiles(absSrcDir, 2)) {
|
||||
TUI.warn(`No documentation content found in ${absSrcDir}`);
|
||||
TUI.info('docmd expects markdown files in the source folder.');
|
||||
|
||||
const err: any = new Error('No content found for documentation.');
|
||||
err.silent = true;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Try extracting defaults from package.json
|
||||
let autoTitle = path.basename(cwd) || 'Documentation';
|
||||
let autoDesc = '';
|
||||
try {
|
||||
const pkgPath = path.join(cwd, 'package.json');
|
||||
if (fs.existsSync(pkgPath)) {
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||
if (pkg.name) {
|
||||
autoTitle = pkg.name.replace(/^@[^/]+\//, '').split('-').map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
||||
}
|
||||
if (pkg.description) autoDesc = pkg.description;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const { versions, i18n } = detectHeuristics(cwd, srcDir);
|
||||
|
||||
// Dynamically build the navigation tree only if we do not have versions/locales at root
|
||||
// (otherwise navigation is handled at version/locale level)
|
||||
const autoNav = (!versions && !i18n) ? buildAutoNav(absSrcDir) : null;
|
||||
|
||||
const autoConfig: any = {
|
||||
title: autoTitle,
|
||||
description: autoDesc,
|
||||
src: srcDir,
|
||||
out: 'site',
|
||||
layout: { spa: true },
|
||||
theme: { name: 'default', appearance: 'system' }
|
||||
};
|
||||
|
||||
if (autoNav) {
|
||||
autoConfig.navigation = autoNav;
|
||||
}
|
||||
if (versions) {
|
||||
autoConfig.versions = versions;
|
||||
}
|
||||
if (i18n) {
|
||||
autoConfig.i18n = i18n;
|
||||
}
|
||||
|
||||
// Merge with global defaults
|
||||
const merged = { ...(options._globalDefaults || {}), ...autoConfig };
|
||||
|
||||
return normalizeConfig(merged);
|
||||
}
|
||||
|
||||
export async function loadConfig(configPath: string, options: any = {}) {
|
||||
// D-H1: honour an explicit `cwd` option (used by build.ts when the
|
||||
// caller passes --config <abs-path> from a foreign working dir). Falls
|
||||
// back to `process.cwd()` so existing callers continue to work.
|
||||
const cwd = options.cwd || process.cwd();
|
||||
|
||||
let absoluteConfigPath = path.resolve(cwd, configPath);
|
||||
|
||||
if (configPath === 'docmd.config.js') {
|
||||
const candidates = [
|
||||
'docmd.config.json',
|
||||
'docmd.config.ts',
|
||||
'docmd.config.js',
|
||||
'docmd.config.mjs',
|
||||
'config.js'
|
||||
];
|
||||
let found = false;
|
||||
for (const c of candidates) {
|
||||
const p = path.resolve(cwd, c);
|
||||
if (fs.existsSync(p)) {
|
||||
absoluteConfigPath = p;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
// Check if we have docs directory with markdown files or if root has them.
|
||||
// If we don't even have a docs directory, or if it's empty, auto-run init:
|
||||
const docCandidates = ['docs', 'src/docs', 'documentation', 'content'];
|
||||
let hasDocs = false;
|
||||
for (const d of docCandidates) {
|
||||
const dp = path.join(cwd, d);
|
||||
if (fs.existsSync(dp) && hasMarkdownFiles(dp, 2)) {
|
||||
hasDocs = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasDocs && hasMarkdownFiles(cwd, 1)) {
|
||||
hasDocs = true;
|
||||
}
|
||||
|
||||
if (!hasDocs) {
|
||||
if (process.argv.includes('--json') || options.quiet) {
|
||||
throw new Error('No documentation directory or configuration found.');
|
||||
}
|
||||
TUI.warn('No documentation or configuration found. Initializing new project...');
|
||||
const { initProject } = await import('../commands/init.js');
|
||||
await initProject();
|
||||
|
||||
// Re-check config candidates
|
||||
for (const c of candidates) {
|
||||
const p = path.resolve(cwd, c);
|
||||
if (fs.existsSync(p)) {
|
||||
absoluteConfigPath = p;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
// Fallback to Zero-Config if nothing is found to prevent crashing!
|
||||
if (!(global as any).__DOCMD_NO_CONFIG_LOGGED && !options.quiet) {
|
||||
TUI.warn('No config found. Running in auto mode. Run `docmd init` to create one.');
|
||||
(global as any).__DOCMD_NO_CONFIG_LOGGED = true;
|
||||
}
|
||||
const autoConfig = await buildZeroConfig(cwd, options.isDev, options.quiet, options);
|
||||
return JSON.parse(JSON.stringify(autoConfig));
|
||||
}
|
||||
}
|
||||
} else if (!fs.existsSync(absoluteConfigPath)) {
|
||||
throw new Error(`Config file not found: ${absoluteConfigPath}`);
|
||||
}
|
||||
|
||||
// Cleanup any orphaned temp config files from previous failed reloads
|
||||
try {
|
||||
const configDir = path.dirname(absoluteConfigPath);
|
||||
const baseName = path.basename(absoluteConfigPath).split('.')[0];
|
||||
const files = fs.readdirSync(configDir);
|
||||
for (const file of files) {
|
||||
if (file.startsWith(`${baseName}-`) && (file.endsWith('.js') || file.endsWith('.mjs'))) {
|
||||
const fullPath = path.join(configDir, file);
|
||||
// If it has a timestamp-like suffix, it's one of ours
|
||||
if (/-[0-9]{13}\.(js|mjs)$/.test(file)) {
|
||||
fs.unlinkSync(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* ignore cleanup errors */ }
|
||||
|
||||
try {
|
||||
// Polyfill defineConfig globally so the config file works
|
||||
// even if @docmd/core isn't installed locally in the target project.
|
||||
(global as any).defineConfig = (config: any) => config;
|
||||
|
||||
// Use a timestamp to bypass ESM cache if file is likely changed
|
||||
const ts = Date.now();
|
||||
let configUrl = pathToFileURL(absoluteConfigPath).href + '?t=' + ts;
|
||||
let tempConfigPath: string | null = null;
|
||||
let rawConfig: any;
|
||||
|
||||
try {
|
||||
if (absoluteConfigPath.endsWith('.json')) {
|
||||
rawConfig = JSON.parse(fs.readFileSync(absoluteConfigPath, 'utf-8'));
|
||||
} else {
|
||||
if (absoluteConfigPath.endsWith('.ts')) {
|
||||
const esbuild = await import('esbuild');
|
||||
tempConfigPath = absoluteConfigPath.replace(/\.ts$/, `-${ts}.mjs`);
|
||||
await esbuild.build({
|
||||
entryPoints: [absoluteConfigPath],
|
||||
outfile: tempConfigPath,
|
||||
format: 'esm',
|
||||
bundle: true,
|
||||
packages: 'external',
|
||||
platform: 'node',
|
||||
target: 'node18'
|
||||
});
|
||||
configUrl = pathToFileURL(tempConfigPath).href;
|
||||
} else if (absoluteConfigPath.endsWith('.js') || absoluteConfigPath.endsWith('.mjs')) {
|
||||
// Copy to a temp file to guarantee cache bypass (query strings
|
||||
// are not always reliable for ESM cache busting in all Node versions)
|
||||
const ext = path.extname(absoluteConfigPath);
|
||||
tempConfigPath = absoluteConfigPath.replace(new RegExp(`\\${ext}$`), `-${ts}${ext}`);
|
||||
fs.copyFileSync(absoluteConfigPath, tempConfigPath);
|
||||
configUrl = pathToFileURL(tempConfigPath).href;
|
||||
}
|
||||
|
||||
const rawModule = await import(configUrl);
|
||||
rawConfig = rawModule.default || rawModule;
|
||||
}
|
||||
|
||||
// If user has 'search' or 'theme' at root, but no 'layout' object, they are legacy.
|
||||
const isLegacy = !rawConfig.layout && (
|
||||
rawConfig.search !== undefined ||
|
||||
(rawConfig.theme && rawConfig.theme.enableModeToggle !== undefined) ||
|
||||
rawConfig.sponsor
|
||||
);
|
||||
|
||||
if (isLegacy) {
|
||||
TUI.error('Legacy Configuration Detected', 'Your docmd.config uses an outdated structure.');
|
||||
TUI.info(`Run ${TUI.cyan('docmd migrate')} to automatically upgrade your configuration.`);
|
||||
}
|
||||
|
||||
validateConfig(rawConfig);
|
||||
const hasExplicitNav = 'navigation' in rawConfig;
|
||||
|
||||
// Merge with global defaults: root project config overrides global defaults
|
||||
const mergedConfig = { ...(options._globalDefaults || {}), ...rawConfig };
|
||||
|
||||
const normalized = normalizeConfig(mergedConfig);
|
||||
|
||||
if (normalized._baseAutoDerived) {
|
||||
TUI.info(`${TUI.dim('base auto-derived from url:')} ${TUI.cyan(normalized._baseAutoDerived)}`);
|
||||
}
|
||||
|
||||
// Navigation Handling: Prioritize local navigation.json in the project folder
|
||||
let navScanDir = path.resolve(cwd, normalized.srcDir);
|
||||
let localNavPath = path.join(navScanDir, 'navigation.json');
|
||||
|
||||
// Adjust navScanDir if i18n is enabled to check in the default locale folder
|
||||
if (normalized.i18n?.default) {
|
||||
const localeScanDir = path.join(navScanDir, normalized.i18n.default);
|
||||
if (fs.existsSync(localeScanDir)) {
|
||||
navScanDir = localeScanDir;
|
||||
localNavPath = path.join(navScanDir, 'navigation.json');
|
||||
}
|
||||
}
|
||||
|
||||
// If navigation.json exists locally, it ALWAYS wins over inherited global navigation
|
||||
if (fs.existsSync(localNavPath)) {
|
||||
try {
|
||||
normalized.navigation = JSON.parse(fs.readFileSync(localNavPath, 'utf-8'));
|
||||
} catch (e: any) {
|
||||
TUI.error('Navigation Error', `Failed to parse ${localNavPath}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
// Otherwise, if we still don't have any navigation (not from global, not from config, not from JSON),
|
||||
// check for localized/versioned nav or fallback to auto-nav
|
||||
else if (!normalized.navigation || (normalized.navigation.length === 0 && !hasExplicitNav)) {
|
||||
let hasNavInSubdirs = false;
|
||||
|
||||
if (normalized.i18n?.default) {
|
||||
// Check if any locale dir has navigation.json
|
||||
hasNavInSubdirs = (normalized.i18n.locales || []).some((l: any) =>
|
||||
fs.existsSync(path.join(path.resolve(cwd, normalized.srcDir), l.id, 'navigation.json'))
|
||||
);
|
||||
}
|
||||
|
||||
// Check if any version dir has navigation.json
|
||||
if (!hasNavInSubdirs && normalized.versions?.all?.length > 0) {
|
||||
hasNavInSubdirs = normalized.versions.all.some((v: any) => {
|
||||
const vDir = path.resolve(cwd, v.dir);
|
||||
if (fs.existsSync(path.join(vDir, 'navigation.json'))) return true;
|
||||
if (normalized.i18n?.default) {
|
||||
return fs.existsSync(path.join(vDir, normalized.i18n.default, 'navigation.json'));
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasNavInSubdirs) {
|
||||
if (!options.quiet && !(global as any).__DOCMD_ZERO_NAV_LOGGED) {
|
||||
TUI.info('No navigation settings found. Auto-generating with Zero-Config...');
|
||||
if (options.isDev) (global as any).__DOCMD_ZERO_NAV_LOGGED = true;
|
||||
}
|
||||
normalized.navigation = buildAutoNav(navScanDir);
|
||||
}
|
||||
}
|
||||
|
||||
normalized._resolvedPath = absoluteConfigPath;
|
||||
|
||||
// Ensure the final configuration object is completely JSON-serialisable
|
||||
// This is crucial for passing the config to worker threads in the new multi-threaded build engine
|
||||
return JSON.parse(JSON.stringify(normalized));
|
||||
} finally {
|
||||
if (tempConfigPath && fs.existsSync(tempConfigPath)) {
|
||||
fs.unlinkSync(tempConfigPath);
|
||||
}
|
||||
// Clean up global to avoid pollution
|
||||
delete (global as any).defineConfig;
|
||||
}
|
||||
|
||||
} catch (e: any) {
|
||||
if (e.message === 'Invalid configuration file.') throw e;
|
||||
throw new Error(`Error parsing config file: ${e.message}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* @package @docmd/core (and ecosystem)
|
||||
* @website https://docmd.io
|
||||
* @repository https://github.com/docmd-io/docmd
|
||||
* @license MIT
|
||||
* @copyright Copyright (c) 2025-present docmd.io
|
||||
*
|
||||
* [docmd-source] - Please do not remove this header.
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { normalizeNavPaths, normalizeMenubarPaths } from '@docmd/parser';
|
||||
|
||||
/**
|
||||
* CSS themes shipped with @docmd/themes. Any other value of `theme.name`
|
||||
* is treated as a template name (see Section 4.0 of normalizeConfig).
|
||||
*
|
||||
* The "none" sentinel value suppresses the CSS overlay entirely.
|
||||
*/
|
||||
const KNOWN_CSS_THEMES: ReadonlySet<string> = new Set([
|
||||
'default',
|
||||
'sky',
|
||||
'ruby',
|
||||
'retro',
|
||||
'none',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Hardcoded English defaults for the 404 page. These are intentionally
|
||||
* NOT injected into `config.notFound` during normalisation — if we did,
|
||||
* the build site's `|| t('pageNotFound')` fallback would never fire
|
||||
* (the user-supplied English string would shadow it), and a site with
|
||||
* default locale = 'zh' would still render "404 : Page Not Found" in
|
||||
* English.
|
||||
*
|
||||
* Instead, the build command resolves the actual title/content at
|
||||
* render-time using this priority chain:
|
||||
* 1. config.notFound.title / config.notFound.content (user override)
|
||||
* 2. t('pageNotFound') / t('pageNotFoundMsg') (translated to default locale)
|
||||
* 3. NOT_FOUND_DEFAULTS.title / content (this constant, English)
|
||||
*
|
||||
* Exported here so the defaults live next to the rest of the config
|
||||
* schema rather than buried in the build command.
|
||||
*/
|
||||
export const NOT_FOUND_DEFAULTS = {
|
||||
title: 'Page Not Found',
|
||||
content: 'The page you’re looking for doesn’t exist or has moved.'
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes user config to ensure all required nested objects exist.
|
||||
* Handles legacy backward compatibility transparently.
|
||||
*/
|
||||
export function normalizeConfig(userConfig: any) {
|
||||
const config = { ...userConfig };
|
||||
|
||||
// --- 1. Modern Syntax Standard (V3) ---
|
||||
// New labels are the source of truth. Fallback to legacy labels if present.
|
||||
// Every field MUST have a default here so no consumer needs its own fallback chain.
|
||||
config.title = config.title || config.siteTitle || 'Documentation';
|
||||
config.url = config.url || config.siteUrl || config.baseUrl || '';
|
||||
config.src = config.src || config.srcDir || config.source || 'docs';
|
||||
config.out = process.env.DOCMD_PROJECT_OUT || config.out || config.outDir || config.outputDir || 'site';
|
||||
|
||||
// Track whether the user explicitly set base (vs. the default '/').
|
||||
// The env var (DOCMD_PROJECT_PREFIX) counts as explicit; only a truly
|
||||
// absent base triggers auto-derivation from url below.
|
||||
const _userSetBaseExplicitly = !!process.env.DOCMD_PROJECT_PREFIX || userConfig.base !== undefined;
|
||||
config.base = process.env.DOCMD_PROJECT_PREFIX || config.base || '/';
|
||||
|
||||
// --- 1.1 Normalise base slashes + auto-derive from URL ---
|
||||
//
|
||||
// DESIGN PRINCIPLE: `base` is an internal implementation detail. Users
|
||||
// should NEVER have to think about it. They set `url` (the canonical
|
||||
// production URL) and docmd derives everything else. The only time
|
||||
// `base` is needed is the rare case where the deployment path differs
|
||||
// from the URL path (e.g. a CDN that strips a prefix).
|
||||
//
|
||||
// What this block does, in order:
|
||||
// 1. If the user explicitly set base, normalise its slashes:
|
||||
// "repo" → "/repo/", "/repo" → "/repo/", "repo/" → "/repo/".
|
||||
// Users should not have to remember whether slashes are needed.
|
||||
// 2. If base was NOT explicitly set, derive it from url's pathname.
|
||||
// This handles GitHub Pages project sites and any subpath deploy
|
||||
// where the user set url to match their deployment.
|
||||
// 3. If neither base nor url is informative, default to "/".
|
||||
//
|
||||
// The derivation is slash-tolerant on url too:
|
||||
// url = "https://user.github.io/repo" → base = "/repo/"
|
||||
// url = "https://user.github.io/repo/" → base = "/repo/"
|
||||
// url = "https://docs.example.com" → base = "/"
|
||||
// url = "https://example.com/docs/" → base = "/docs/"
|
||||
//
|
||||
// If derivation cannot fire (no url, or url is a root), base stays "/"
|
||||
// and the build will warn if assets turn out to be unreachable.
|
||||
if (_userSetBaseExplicitly && config.base !== '/') {
|
||||
// User set base explicitly: fix the slashes for them.
|
||||
let b = config.base.trim();
|
||||
if (!b.startsWith('/')) b = '/' + b; // "repo" → "/repo"
|
||||
if (!b.endsWith('/')) b = b + '/'; // "/repo" → "/repo/"
|
||||
// Collapse accidental double slashes (e.g. "//repo///" → "/repo/").
|
||||
b = b.replace(/([^:])\/{2,}/g, '$1/');
|
||||
if (b !== config.base) config._baseNormalised = config.base;
|
||||
config.base = b;
|
||||
} else if (!_userSetBaseExplicitly && config.url) {
|
||||
// Derive base from url. Slash-tolerant on both ends.
|
||||
try {
|
||||
const parsedUrl = new URL(config.url);
|
||||
// Strip trailing slashes to get a clean path, then re-add one.
|
||||
// Empty or "/" → root deployment, base stays "/".
|
||||
const pathname = parsedUrl.pathname.replace(/\/+$/, '');
|
||||
if (pathname && pathname !== '/') {
|
||||
const derivedBase = pathname + '/';
|
||||
config.base = derivedBase;
|
||||
config._baseAutoDerived = derivedBase;
|
||||
}
|
||||
} catch {
|
||||
// Invalid url (placeholder, typo, empty-ish) — leave base as "/".
|
||||
// The build will surface a clear warning if assets end up missing.
|
||||
}
|
||||
}
|
||||
// Top-level QoL defaults — opt out by setting `false`.
|
||||
if (config.pageNavigation === undefined) config.pageNavigation = true;
|
||||
if (config.copyCode === undefined) config.copyCode = true;
|
||||
if (config.autoTitleFromH1 === undefined) config.autoTitleFromH1 = true;
|
||||
|
||||
// --- 1.5 Security defaults (Phase 0.D, new in v0.8.8) ---
|
||||
// Controls how the markdown parser handles raw HTML in user content.
|
||||
// 'allow' - raw HTML passes through to the rendered output (UNSAFE)
|
||||
// 'escape' - raw HTML is HTML-escaped and shown as text (default)
|
||||
// 'strip' - raw HTML blocks are removed from the rendered output
|
||||
// The default flips from allow to escape in v0.8.8 to mitigate S-2 and S-7.
|
||||
const VALID_HTML_POLICIES = new Set(['allow', 'escape', 'strip']);
|
||||
const userHtmlPolicy = config.security && config.security.html;
|
||||
config.security = {
|
||||
html: VALID_HTML_POLICIES.has(userHtmlPolicy) ? userHtmlPolicy : 'escape',
|
||||
};
|
||||
|
||||
// Failsafe: Keep legacy keys attached for older plugins (SEO, Sitemap) to prevent breakage during transition.
|
||||
config.siteTitle = config.title;
|
||||
config.siteUrl = config.url;
|
||||
config.srcDir = config.src;
|
||||
config.outputDir = config.out;
|
||||
|
||||
// --- Logo Normalization
|
||||
if (typeof config.logo === 'string') {
|
||||
config.logo = {
|
||||
light: config.logo,
|
||||
dark: config.logo,
|
||||
alt: config.title || 'Logo'
|
||||
};
|
||||
}
|
||||
|
||||
// --- 2. Layout Structure (V2 Schema) ---
|
||||
const userLayout = config.layout || {};
|
||||
|
||||
config.layout = {
|
||||
spa: true,
|
||||
breadcrumbs: true,
|
||||
...userLayout
|
||||
};
|
||||
|
||||
config.header = {
|
||||
enabled: true,
|
||||
...(userLayout.header || config.header || {})
|
||||
};
|
||||
|
||||
// Legacy Mapping: Sidebar
|
||||
const legacySidebar = config.sidebar || {};
|
||||
config.sidebar = {
|
||||
enabled: true,
|
||||
collapsible: true,
|
||||
defaultCollapsed: false,
|
||||
position: 'left',
|
||||
...(userLayout.sidebar || legacySidebar)
|
||||
};
|
||||
|
||||
// Legacy Mapping: Footer
|
||||
const legacyFooter = config.footer;
|
||||
config.footer = {
|
||||
copyright: `© ${new Date().getFullYear()}`,
|
||||
style: 'minimal',
|
||||
content: typeof legacyFooter === 'string' ? legacyFooter : null,
|
||||
branding: true,
|
||||
...(userLayout.footer || (typeof legacyFooter === 'object' ? legacyFooter : {}))
|
||||
};
|
||||
|
||||
if (config.footer.columns && Array.isArray(config.footer.columns)) {
|
||||
for (const col of config.footer.columns) {
|
||||
if (col.links && Array.isArray(col.links)) {
|
||||
normalizeMenubarPaths(col.links);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3. Options Menu (Search, Theme, Sponsor) ---
|
||||
config.optionsMenu = {
|
||||
position: 'header',
|
||||
components: {
|
||||
search: true,
|
||||
themeSwitch: true,
|
||||
sponsor: null
|
||||
},
|
||||
...(userLayout.optionsMenu || config.optionsMenu || {})
|
||||
};
|
||||
|
||||
// --- 3.1. Site-wide Banner (new in 0.8.7) ---
|
||||
// Sits above the menubar. Opt-in — defaults to null (no banner rendered).
|
||||
if (config.layout?.banner) {
|
||||
const ub = config.layout.banner;
|
||||
config.layout.banner = {
|
||||
content: typeof ub === 'string' ? ub : (ub.content || ub.html || ''),
|
||||
html: typeof ub === 'object' && ub.html ? ub.html : undefined,
|
||||
type: (typeof ub === 'object' && ub.type) ? ub.type : 'info',
|
||||
dismissible: typeof ub === 'object' && ub.dismissible === false ? false : true,
|
||||
link: typeof ub === 'object' && ub.link && ub.link.url ? ub.link : null,
|
||||
icon: typeof ub === 'object' && ub.icon ? ub.icon : null,
|
||||
};
|
||||
// If only a string was passed, `ub` is a string and `content` is the string.
|
||||
// If it's an object, ensure `content` and `html` are mutually consistent.
|
||||
if (config.layout.banner.html && !config.layout.banner.content) {
|
||||
config.layout.banner.content = config.layout.banner.html;
|
||||
}
|
||||
} else {
|
||||
config.layout.banner = null;
|
||||
}
|
||||
|
||||
// --- Menubar (Top Navigation Bar) ---
|
||||
const userMenubar = userLayout.menubar || config.menubar;
|
||||
if (userMenubar) {
|
||||
const isArray = Array.isArray(userMenubar);
|
||||
config.menubar = {
|
||||
enabled: true,
|
||||
position: (!isArray && userMenubar.position) ? userMenubar.position : 'top',
|
||||
left: isArray ? userMenubar : (Array.isArray(userMenubar.left) ? userMenubar.left : []),
|
||||
right: (!isArray && Array.isArray(userMenubar.right)) ? userMenubar.right : [],
|
||||
...(!isArray ? userMenubar : {})
|
||||
};
|
||||
normalizeMenubarPaths(config.menubar.left);
|
||||
normalizeMenubarPaths(config.menubar.right);
|
||||
} else {
|
||||
config.menubar = null;
|
||||
}
|
||||
|
||||
// --> Legacy Adapter: Sponsor
|
||||
if (config.sponsor) {
|
||||
if (typeof config.sponsor === 'object' && config.sponsor.enabled && config.sponsor.link) {
|
||||
config.optionsMenu.components.sponsor = config.sponsor.link;
|
||||
} else if (typeof config.sponsor === 'string') {
|
||||
config.optionsMenu.components.sponsor = config.sponsor;
|
||||
}
|
||||
}
|
||||
|
||||
// --> Legacy Adapter: Search (Boolean)
|
||||
if (typeof config.search === 'boolean') {
|
||||
config.optionsMenu.components.search = config.search;
|
||||
}
|
||||
|
||||
// --> Legacy Adapter: Theme Switch & Position
|
||||
if (config.theme) {
|
||||
if (config.theme.enableModeToggle === false) {
|
||||
config.optionsMenu.components.themeSwitch = false;
|
||||
}
|
||||
if (config.theme.positionMode === 'bottom') {
|
||||
config.optionsMenu.position = 'sidebar-bottom';
|
||||
} else if (config.theme.positionMode === 'top') {
|
||||
config.optionsMenu.position = 'header';
|
||||
}
|
||||
}
|
||||
|
||||
// --- 4. Theme & Branding ---
|
||||
config.theme = {
|
||||
name: 'default',
|
||||
appearance: 'system',
|
||||
customCss: [],
|
||||
codeHighlight: true,
|
||||
...(config.theme || {})
|
||||
};
|
||||
|
||||
// Legacy Support: Map defaultMode to appearance if appearance isn't explicitly set
|
||||
if (config.theme.defaultMode && !userConfig.theme?.appearance) {
|
||||
config.theme.appearance = config.theme.defaultMode;
|
||||
}
|
||||
|
||||
// Ensure defaultMode is still available for legacy templates/plugins
|
||||
config.theme.defaultMode = config.theme.appearance;
|
||||
|
||||
// --- 4.0. Theme name → Template auto-promotion (new in 0.8.7) ---
|
||||
// The CSS themes shipped with @docmd/themes are a known, short list.
|
||||
// Any other value in `theme.name` is treated as a template name (so
|
||||
// users only need to learn ONE key: `theme.name`).
|
||||
// Explicit `theme.template` always wins.
|
||||
if (config.theme.name && !config.theme.template && !KNOWN_CSS_THEMES.has(config.theme.name)) {
|
||||
config.theme.template = config.theme.name;
|
||||
// Keep `theme.name` so the original intent is preserved, but
|
||||
// mark the theme as "no CSS overlay" so the generator does not
|
||||
// try to load `docmd-theme-${name}.css` (which would 404).
|
||||
config.theme._noCssOverlay = true;
|
||||
}
|
||||
|
||||
// --- 4.1. Cookie Consent (new in 0.8.7) ---
|
||||
// Opt-in. Users add `"cookie": { ... }` to enable the consent dialog.
|
||||
// Defaults are kept conservative; templates can ship their own defaults
|
||||
// by reading config.cookie and supplying a copy in their template's
|
||||
// onboarding step. The user is always in control.
|
||||
if (config.cookie) {
|
||||
const uc = config.cookie;
|
||||
if (uc === true) {
|
||||
config.cookie = { enabled: true };
|
||||
} else if (typeof uc === 'object') {
|
||||
config.cookie = {
|
||||
enabled: uc.enabled !== false,
|
||||
message: uc.message || null,
|
||||
acceptText: uc.acceptText || null,
|
||||
declineText: uc.declineText || null,
|
||||
policyUrl: uc.policyUrl || null,
|
||||
position: ['bottom', 'bottom-left', 'bottom-right', 'center'].includes(uc.position) ? uc.position : 'bottom',
|
||||
dismissible: uc.dismissible !== false,
|
||||
expiryDays: typeof uc.expiryDays === 'number' && uc.expiryDays > 0 ? uc.expiryDays : 180,
|
||||
};
|
||||
} else {
|
||||
config.cookie = null;
|
||||
}
|
||||
} else {
|
||||
config.cookie = null;
|
||||
}
|
||||
|
||||
config.customJs = config.customJs || [];
|
||||
|
||||
// Normalize Navigation
|
||||
config.navigation = Array.isArray(config.navigation) ? config.navigation : [];
|
||||
normalizeNavPaths(config.navigation);
|
||||
|
||||
// Aliasing for Menubar items (title -> text, path -> url)
|
||||
if (config.menubar) {
|
||||
const normalizeItems = (items: any[]) => {
|
||||
items.forEach(item => {
|
||||
if (item.title && !item.text) item.text = item.title;
|
||||
if (item.path && !item.url) item.url = item.path;
|
||||
if (item.items) normalizeItems(item.items);
|
||||
});
|
||||
};
|
||||
if (config.menubar.left) normalizeItems(config.menubar.left);
|
||||
if (config.menubar.right) normalizeItems(config.menubar.right);
|
||||
}
|
||||
|
||||
// --- 5. Plugins ---
|
||||
config.hasExplicitPlugins = 'plugins' in userConfig;
|
||||
config.plugins = config.plugins || {};
|
||||
|
||||
// --- 6. Versioning Engine ---
|
||||
// M-6: accept `config.versions.list` as an alias for `config.versions.all`.
|
||||
// The original audit reported "i18n + explicit versions: 0 pages (silent)"
|
||||
// — turns out the actual cause was a typo: users wrote `list` (a
|
||||
// common shape for "list of versions") but the schema only accepted
|
||||
// `all`, so the config branch was never entered and zero pages got
|
||||
// built. Aliasing `list` to `all` here restores the user's config
|
||||
// without changing the canonical key.
|
||||
if (config.versions && Array.isArray(config.versions.list) && !Array.isArray(config.versions.all)) {
|
||||
config.versions.all = config.versions.list;
|
||||
}
|
||||
if (config.versions && Array.isArray(config.versions.all)) {
|
||||
if (!config.versions.current) {
|
||||
config.versions.current = config.versions.all[0]?.id || 'main';
|
||||
}
|
||||
config.versions.position = config.versions.position || 'sidebar-top';
|
||||
config.versions.all = config.versions.all.map((v: any) => {
|
||||
return {
|
||||
id: v.id,
|
||||
dir: v.dir || `docs-${v.id}`,
|
||||
label: v.label || v.id,
|
||||
navigation: v.navigation || null
|
||||
};
|
||||
});
|
||||
} else {
|
||||
config.versions = false;
|
||||
}
|
||||
|
||||
// --- 7. SEO Redirects & 404 ---
|
||||
// config.notFound is normalised to an empty object here so build
|
||||
// commands can read `config.notFound?.title` / `content` without
|
||||
// optional-chaining every access. The English defaults live in the
|
||||
// exported NOT_FOUND_DEFAULTS constant and are applied at render
|
||||
// time (NOT pre-injected into config.notFound — that would shadow
|
||||
// the translation fallback chain in the build command).
|
||||
config.redirects = config.redirects || {};
|
||||
config.notFound = config.notFound || {};
|
||||
|
||||
// --- 8. Internationalisation (i18n) ---
|
||||
if (config.i18n && config.i18n.locales && Array.isArray(config.i18n.locales) && config.i18n.locales.length > 0) {
|
||||
config.i18n = {
|
||||
default: config.i18n.default || config.i18n.locales[0].id || 'en',
|
||||
position: config.i18n.position || 'options-menu',
|
||||
stringMode: config.i18n.stringMode || false,
|
||||
inPlace: config.i18n.inPlace || false,
|
||||
locales: config.i18n.locales.map((loc: any) => ({
|
||||
id: loc.id,
|
||||
label: loc.label || loc.id,
|
||||
dir: loc.dir || 'ltr',
|
||||
translations: loc.translations || {}
|
||||
}))
|
||||
};
|
||||
} else {
|
||||
config.i18n = false;
|
||||
}
|
||||
|
||||
// --- 9. OptionsMenu Fallbacks ---
|
||||
if (config.optionsMenu.position === 'menubar' && (!config.menubar || config.menubar.enabled === false)) {
|
||||
config.optionsMenu.position = 'sidebar-top';
|
||||
} else if (config.optionsMenu.position === 'header' && (!config.header || config.header.enabled === false)) {
|
||||
config.optionsMenu.position = 'sidebar-top';
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
// Re-export for backward compatibility (used by generator.ts, versioning.ts)
|
||||
export { normalizeNavPaths, normalizeMenubarPaths } from '@docmd/parser';
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* @package @docmd/core (and ecosystem)
|
||||
* @website https://docmd.io
|
||||
* @repository https://github.com/docmd-io/docmd
|
||||
* @license MIT
|
||||
* @copyright Copyright (c) 2025-present docmd.io
|
||||
*
|
||||
* [docmd-source] - Please do not remove this header.
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { fsUtils as fs, safePath, asUserPath } from '@docmd/utils';
|
||||
|
||||
|
||||
|
||||
// MIME types for static file serving
|
||||
export const MIME_TYPES: Record<string, string> = {
|
||||
'.html': 'text/html',
|
||||
'.js': 'text/javascript',
|
||||
'.css': 'text/css',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpg',
|
||||
'.jpeg': 'image/jpg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff': 'application/font-woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ttf': 'application/font-ttf',
|
||||
'.txt': 'text/plain',
|
||||
};
|
||||
|
||||
/**
|
||||
* Format an absolute path for display relative to CWD.
|
||||
*/
|
||||
export function formatPathForDisplay(absolutePath: string, cwd: string): string {
|
||||
const relativePath = path.relative(cwd, absolutePath);
|
||||
if (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)) {
|
||||
return `./${relativePath}`;
|
||||
}
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first non-internal IPv4 network address.
|
||||
*/
|
||||
export function getNetworkIp(): string | null {
|
||||
const interfaces = os.networkInterfaces();
|
||||
for (const name of Object.keys(interfaces)) {
|
||||
for (const iface of interfaces[name]!) {
|
||||
if (iface.family === 'IPv4' && !iface.internal) {
|
||||
return iface.address;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read git user.name and user.email, compute Gravatar URL.
|
||||
* Lazy-initialized on first call.
|
||||
*/
|
||||
let _gitDevInfoCache: { name: string; email: string; gravatarUrl: string } | null = null;
|
||||
export function getGitDevInfo() {
|
||||
if (_gitDevInfoCache) return _gitDevInfoCache;
|
||||
let name = '';
|
||||
let email = '';
|
||||
try { name = execSync('git config user.name', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim(); } catch { /* git not configured */ }
|
||||
try { email = execSync('git config user.email', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim(); } catch { /* git not configured */ }
|
||||
const gravatarUrl = email
|
||||
? `https://gravatar.com/avatar/${crypto.createHash('md5').update(email.toLowerCase().trim()).digest('hex')}?s=80&d=mp`
|
||||
: '';
|
||||
_gitDevInfoCache = { name, email, gravatarUrl };
|
||||
return _gitDevInfoCache;
|
||||
}
|
||||
|
||||
export function getDevInfoScript(): string {
|
||||
return `<script>window.__docmd_dev=${JSON.stringify(getGitDevInfo())}</script>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve static files from rootDir with live-reload injection.
|
||||
*/
|
||||
export async function serveStatic(req: any, res: any, rootDir: string) {
|
||||
// Serve dev-only API script
|
||||
if (req.url === '/__dev/docmd-api.js') {
|
||||
try {
|
||||
const apiScriptPath = path.resolve(
|
||||
fileURLToPath(import.meta.url),
|
||||
'../../../../ui/assets/js/docmd-api.js'
|
||||
);
|
||||
const apiScript = await fs.readFile(apiScriptPath, 'utf-8');
|
||||
res.writeHead(200, { 'Content-Type': 'text/javascript' });
|
||||
res.end(apiScript);
|
||||
} catch {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url || '/', 'http://localhost');
|
||||
const pathname = decodeURIComponent(url.pathname);
|
||||
const rootAbs = path.resolve(rootDir);
|
||||
// Security (Phase 1.A: CWE-22 fix). Use safePath() to enforce the boundary.
|
||||
//
|
||||
// The previous `filePath.startsWith(rootAbs)` check was the outlier in the
|
||||
// repo: it did not append `path.sep`, so a sibling directory whose name
|
||||
// started with rootDir's name (e.g. rootDir=`/x/site`, sibling=`/x/site-private`)
|
||||
// passed the check, and a URL like `/..%2fsite-private/secret.txt` resolved
|
||||
// to the sibling. safePath() enforces the strict `root + path.sep` boundary
|
||||
// and throws on escape.
|
||||
//
|
||||
// Note: URL pathnames always start with `/` (e.g. `/index.html`). We
|
||||
// strip the leading slash before passing to safePath() so the second
|
||||
// argument is treated as a *relative* path. `path.resolve` (which
|
||||
// safePath uses internally) treats `/index.html` as absolute and would
|
||||
// bypass the boundary check; `path.join`-style resolution is the
|
||||
// correct semantic for "join this to the root".
|
||||
let filePath: string;
|
||||
try {
|
||||
const safeRelative = pathname.replace(/^\/+/, '') || '.';
|
||||
filePath = safePath(rootAbs, asUserPath(safeRelative));
|
||||
} catch (_e: any) {
|
||||
res.writeHead(403);
|
||||
res.end('Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let stats;
|
||||
try {
|
||||
stats = await fs.stat(filePath);
|
||||
} catch (e) {
|
||||
if (path.extname(filePath) === '') {
|
||||
filePath += '.html';
|
||||
stats = await fs.stat(filePath);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
if (!req.url.split('?')[0].endsWith('/')) {
|
||||
res.writeHead(301, { 'Location': req.url + '/' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
filePath = path.join(filePath, 'index.html');
|
||||
await fs.stat(filePath);
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
||||
const content = await fs.readFile(filePath);
|
||||
|
||||
if (contentType === 'text/html') {
|
||||
res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-cache' });
|
||||
const htmlStr = content.toString('utf-8');
|
||||
const liveReloadScript = `${getDevInfoScript()}<script src="/__dev/docmd-api.js"></script></body>`;
|
||||
res.end(htmlStr.replace('</body>', liveReloadScript));
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': contentType });
|
||||
res.end(content);
|
||||
}
|
||||
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ENOENT') {
|
||||
const custom404Path = path.join(rootDir, '404.html');
|
||||
try {
|
||||
const content = await fs.readFile(custom404Path);
|
||||
res.writeHead(404, { 'Content-Type': 'text/html' });
|
||||
const htmlStr = content.toString('utf-8');
|
||||
const liveReloadScript = `${getDevInfoScript()}<script src="/__dev/docmd-api.js"></script></body>`;
|
||||
res.end(htmlStr.replace('</body>', liveReloadScript));
|
||||
} catch {
|
||||
res.writeHead(404, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<div style="font-family:system-ui;text-align:center;padding:50px;">
|
||||
<h1>404 Not Found</h1>
|
||||
<p>The requested URL <code>${req.url}</code> was not found.</p>
|
||||
<p style="color:#666;font-size:0.9em;">(docmd dev server)</p>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
} else {
|
||||
res.writeHead(500);
|
||||
res.end(`Server Error: ${err.code}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a port is in use.
|
||||
*/
|
||||
export function checkPortInUse(port: number): Promise<boolean> {
|
||||
// The probe binds to 127.0.0.1, not 0.0.0.0, so it only checks whether
|
||||
// the loopback interface has the port. Binding to all interfaces here
|
||||
// was unnecessary (we just need to know if "our" port is taken) and
|
||||
// briefly exposed the probe socket to the LAN.
|
||||
return new Promise((resolve) => {
|
||||
const tester = http.createServer()
|
||||
.once('error', (err: any) => resolve(err.code === 'EADDRINUSE'))
|
||||
.once('listening', () => tester.close(() => resolve(false)))
|
||||
.listen(port, '127.0.0.1');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the next available port starting from startPort.
|
||||
*/
|
||||
export async function findAvailablePort(startPort: number): Promise<number> {
|
||||
let port = startPort;
|
||||
while (await checkPortInUse(port)) {
|
||||
port++;
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a URL in the user's default browser. Best-effort: failures are
|
||||
* swallowed so the dev server is never taken down by a missing `xdg-open`
|
||||
* (e.g. inside the official Docker image, where the binary is not
|
||||
* installed). The call is also skipped entirely when running inside a
|
||||
* container — the official image sets DOCMD_CONTAINER=true for this.
|
||||
*/
|
||||
export function openBrowser(url: string): void {
|
||||
if (process.env.DOCMD_CONTAINER === 'true') return;
|
||||
|
||||
let command = 'xdg-open';
|
||||
let args = [url];
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
command = 'open';
|
||||
} else if (process.platform === 'win32') {
|
||||
command = 'cmd';
|
||||
args = ['/c', 'start', '""', url];
|
||||
}
|
||||
|
||||
try {
|
||||
const child = spawn(command, args, { stdio: 'ignore', detached: true });
|
||||
child.on('error', () => { /* missing browser binary — ignore */ });
|
||||
child.unref();
|
||||
} catch {
|
||||
/* never let a browser-launch failure crash the dev server */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* @package @docmd/core
|
||||
* @website https://docmd.io
|
||||
* @repository https://github.com/docmd-io/docmd
|
||||
* @license MIT
|
||||
* @copyright Copyright (c) 2025-present docmd.io
|
||||
*
|
||||
* [docmd-source] - Please do not remove this header.
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Phase 3 PR 3.A — Exit-code contract.
|
||||
*
|
||||
* Every `docmd <command>` that hits a documented error path MUST exit
|
||||
* with a non-zero code so CI pipelines can gate on it. Historically we
|
||||
* had several `TUI.error(...); return;` patterns that printed the
|
||||
* message but left the process at exit code 0, silently passing broken
|
||||
* builds (see `unified-issues.md` §F6 / §M-12).
|
||||
*
|
||||
* These helpers centralise the contract:
|
||||
*
|
||||
* - `exitCodeFor(err)` returns a numeric exit code. Today this is
|
||||
* always 1 (operational error), but the indirection lets us
|
||||
* introduce code-2 for usage errors, code-3 for config errors,
|
||||
* etc. without rewriting every call site.
|
||||
*
|
||||
* - `exitWithError(err, opts?)` calls `TUI.error(...)` (or prints
|
||||
* JSON when `opts.json === true`) and then `process.exit(code)`.
|
||||
* The `quiet` option suppresses the error print when the caller
|
||||
* has already surfaced the error.
|
||||
*
|
||||
* - `failWith(message, opts?)` is the convenience wrapper for the
|
||||
* common "no extra Error object, just a message" case.
|
||||
*
|
||||
* Every CLI command that previously did `TUI.error(...); return;`
|
||||
* should be ported to `exitWithError(...)`. The Phase 3 PR 3.A brute-
|
||||
* test scenario asserts that every documented failure case exits 1.
|
||||
*/
|
||||
|
||||
import { TUI } from '@docmd/api';
|
||||
|
||||
/**
|
||||
* Map any thrown or constructed value to a numeric exit code.
|
||||
*
|
||||
* Reserved codes (matches common CLI conventions):
|
||||
* 0 — success
|
||||
* 1 — operational error (default for any thrown value)
|
||||
*
|
||||
* Future expansion: discriminate on `err.code` or `err.name` to
|
||||
* return 2 (usage), 3 (config), 4 (network), 5 (I/O), 6 (plugin).
|
||||
*/
|
||||
export function exitCodeFor(_err: unknown): number {
|
||||
// Reserved for future discrimination on err.code / err.name. Today every
|
||||
// error maps to 1 (operational). The parameter is part of the contract.
|
||||
void _err;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print an error and exit with the code returned by `exitCodeFor`.
|
||||
* Never returns — the function always terminates the process.
|
||||
*
|
||||
* exitWithError(new Error('Plugin not found: foo'));
|
||||
* exitWithError('Build failed: ...', { json: true });
|
||||
* exitWithError('Missing configuration', { quiet: true });
|
||||
*/
|
||||
export function exitWithError(
|
||||
err: unknown,
|
||||
opts: { json?: boolean; quiet?: boolean; section?: string } = {}
|
||||
): never {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const code = exitCodeFor(err);
|
||||
|
||||
if (!opts.quiet) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ error: message, exitCode: code }));
|
||||
} else {
|
||||
TUI.error(opts.section || 'Command failed', message);
|
||||
}
|
||||
}
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper when the caller has a plain message string and
|
||||
* no Error object. Same as `exitWithError(new Error(message), opts)`.
|
||||
*/
|
||||
export function failWith(message: string, opts: { json?: boolean; section?: string } = {}): never {
|
||||
exitWithError(new Error(message), opts);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Origin-allowlist verifyClient callback factory for the dev-server
|
||||
* WebSocketServers. Used by packages/core/src/commands/dev.ts and
|
||||
* packages/core/src/engine/workspace.ts. Mitigates CSWSH
|
||||
* (Cross-Site WebSocket Hijacking, CWE-1385). See
|
||||
* DEVELOPMENT-BENCHMARK.md §S3.
|
||||
*/
|
||||
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
|
||||
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
|
||||
|
||||
/**
|
||||
* Extract the hostname (lowercased) from an Origin URL header value.
|
||||
* Returns null if the value cannot be parsed.
|
||||
*/
|
||||
function originHost(originHeader: string | undefined | null): string | null {
|
||||
if (!originHeader) return null;
|
||||
try {
|
||||
const u = new URL(originHeader);
|
||||
return u.hostname.toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface VerifyClientInfo {
|
||||
origin?: string;
|
||||
req: IncomingMessage;
|
||||
secure: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a verifyClient callback that allows loopback origins plus an
|
||||
* optional list of explicit additional hosts (e.g. a known LAN IP).
|
||||
* The port portion of the origin is intentionally ignored — we only
|
||||
* gate by hostname, because the dev server's port may vary per run.
|
||||
*/
|
||||
export function createOriginVerify(extraAllowedHosts: string[] = []) {
|
||||
const allowed = new Set<string>([...LOOPBACK_HOSTS]);
|
||||
for (const h of extraAllowedHosts) {
|
||||
if (h) allowed.add(h.toLowerCase());
|
||||
}
|
||||
|
||||
return function verifyClient(
|
||||
info: VerifyClientInfo,
|
||||
callback: (allowed: boolean, code?: number, message?: string) => void
|
||||
): void {
|
||||
const originHeader = info.origin || (info.req.headers.origin as string | undefined);
|
||||
const host = originHost(originHeader);
|
||||
if (!host) {
|
||||
// No Origin header at all (direct script / curl) — reject for safety.
|
||||
callback(false, 403, 'Origin header required');
|
||||
return;
|
||||
}
|
||||
if (allowed.has(host)) {
|
||||
callback(true);
|
||||
return;
|
||||
}
|
||||
callback(false, 403, `Origin ${host} not in allowlist`);
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user