Compare commits

...

5 Commits

Author SHA1 Message Date
yusing
aaa3c9a8d8 fix(swagger): correct type names in swagger docs
Rename icon-related types in swagger docs:
- homepage.FetchResult → iconfetch.Result
- homepage.IconMetaSearch → iconlist.IconMetaSearch
- homepage.IconSource → icons.Source
- Shorten enum varnames (IconSourceAbsolute → SourceAbsolute, etc.)
- Add x-nullable: true to rules arrays
2026-01-10 15:57:56 +08:00
yusing
bc44de3196 feat(rules): add "on: default" rule syntax for default rule
- Add OnDefault rule type that matches when no other rules match
- Add validation to prevent multiple default rules
- Fix typo: extension → extensions in route config JSON tag
2026-01-10 15:53:26 +08:00
yusing
12b784d126 feat(serialization): add validation support for custom slice types
Enhanced the ConvertSlice function to include validation for destination slices that implement the CustomValidator interface. If validation fails, errors are collected and returned, ensuring data integrity during slice conversion.
2026-01-10 15:49:58 +08:00
yusing
71f6636cc3 refactor(serialization): optimize deserialization 2026-01-10 15:43:34 +08:00
yusing
cc1fe30045 refactor(scripts/wiki): rewrite markdown links when syncing impl docs to wiki
- Convert intra-repo README links to VitePress routes for SPA navigation
- Rewrite source file references (e.g., config.go:29) to GitHub blob links
- Makefile now passes REPO_URL to update-wiki for link rewriting
- Correct agent README.md file links from full to relative paths
- skip introduction.md when syncing
2026-01-10 13:54:22 +08:00
14 changed files with 413 additions and 111 deletions

View File

@@ -3,6 +3,8 @@ export VERSION ?= $(shell git describe --tags --abbrev=0)
export BUILD_DATE ?= $(shell date -u +'%Y%m%d-%H%M')
export GOOS = linux
REPO_URL ?= https://github.com/yusing/godoxy
WEBUI_DIR ?= ../godoxy-webui
DOCS_DIR ?= ${WEBUI_DIR}/wiki
@@ -175,4 +177,4 @@ gen-api-types: gen-swagger
.PHONY: update-wiki
update-wiki:
DOCS_DIR=${DOCS_DIR} bun --bun scripts/update-wiki/main.ts
DOCS_DIR=${DOCS_DIR} REPO_URL=${REPO_URL} bun --bun scripts/update-wiki/main.ts

View File

@@ -27,26 +27,26 @@ graph TD
## File Structure
| File | Purpose |
| -------------------------------------------------------- | --------------------------------------------------------- |
| [`config.go`](agent/pkg/agent/config.go) | Core configuration, initialization, and API client logic. |
| [`new_agent.go`](agent/pkg/agent/new_agent.go) | Agent creation and certificate generation logic. |
| [`docker_compose.go`](agent/pkg/agent/docker_compose.go) | Generator for agent Docker Compose configurations. |
| [`bare_metal.go`](agent/pkg/agent/bare_metal.go) | Generator for bare metal installation scripts. |
| [`env.go`](agent/pkg/agent/env.go) | Environment configuration types and constants. |
| [`common/`](agent/pkg/agent/common) | Shared constants and utilities for agents. |
| File | Purpose |
| ---------------------------------------- | --------------------------------------------------------- |
| [`config.go`](config.go) | Core configuration, initialization, and API client logic. |
| [`new_agent.go`](new_agent.go) | Agent creation and certificate generation logic. |
| [`docker_compose.go`](docker_compose.go) | Generator for agent Docker Compose configurations. |
| [`bare_metal.go`](bare_metal.go) | Generator for bare metal installation scripts. |
| [`env.go`](env.go) | Environment configuration types and constants. |
| `common/` | Shared constants and utilities for agents. |
## Core Types
### [`AgentConfig`](agent/pkg/agent/config.go:29)
### [`AgentConfig`](config.go:29)
The primary struct used by the GoDoxy server to manage a connection to an agent. It stores the agent's address, metadata, and TLS configuration.
### [`AgentInfo`](agent/pkg/agent/config.go:45)
### [`AgentInfo`](config.go:45)
Contains basic metadata about the agent, including its version, name, and container runtime (Docker or Podman).
### [`PEMPair`](agent/pkg/agent/new_agent.go:53)
### [`PEMPair`](new_agent.go:53)
A utility struct for handling PEM-encoded certificate and key pairs, supporting encryption, decryption, and conversion to `tls.Certificate`.
@@ -54,7 +54,7 @@ A utility struct for handling PEM-encoded certificate and key pairs, supporting
### Certificate Generation
The [`NewAgent`](agent/pkg/agent/new_agent.go:147) function creates a complete certificate infrastructure for an agent:
The [`NewAgent`](new_agent.go:147) function creates a complete certificate infrastructure for an agent:
- **CA Certificate**: Self-signed root certificate with 1000-year validity.
- **Server Certificate**: For the agent's HTTPS server, signed by the CA.
@@ -65,18 +65,18 @@ All certificates use ECDSA with P-256 curve and SHA-256 signatures.
### Certificate Security
- Certificates are encrypted using AES-GCM with a provided encryption key.
- The [`PEMPair`](agent/pkg/agent/new_agent.go:53) struct provides methods for encryption, decryption, and conversion to `tls.Certificate`.
- The [`PEMPair`](new_agent.go:53) struct provides methods for encryption, decryption, and conversion to `tls.Certificate`.
- Base64 encoding is used for certificate storage and transmission.
## Key Features
### 1. Secure Communication
All communication between the GoDoxy server and agents is secured using mutual TLS (mTLS). The [`AgentConfig`](agent/pkg/agent/config.go:29) handles the loading of CA and client certificates to establish secure connections.
All communication between the GoDoxy server and agents is secured using mutual TLS (mTLS). The [`AgentConfig`](config.go:29) handles the loading of CA and client certificates to establish secure connections.
### 2. Agent Discovery and Initialization
The [`Init`](agent/pkg/agent/config.go:231) and [`InitWithCerts`](agent/pkg/agent/config.go:110) methods allow the server to:
The [`Init`](config.go:231) and [`InitWithCerts`](config.go:110) methods allow the server to:
- Fetch agent metadata (version, name, runtime).
- Verify compatibility between server and agent versions.
@@ -86,12 +86,12 @@ The [`Init`](agent/pkg/agent/config.go:231) and [`InitWithCerts`](agent/pkg/agen
The package provides interfaces and implementations for generating deployment artifacts:
- **Docker Compose**: Generates a `docker-compose.yml` for running the agent as a container via [`AgentComposeConfig.Generate()`](agent/pkg/agent/docker_compose.go:21).
- **Bare Metal**: Generates a shell script to install and run the agent as a systemd service via [`AgentEnvConfig.Generate()`](agent/pkg/agent/bare_metal.go:27).
- **Docker Compose**: Generates a `docker-compose.yml` for running the agent as a container via [`AgentComposeConfig.Generate()`](docker_compose.go:21).
- **Bare Metal**: Generates a shell script to install and run the agent as a systemd service via [`AgentEnvConfig.Generate()`](bare_metal.go:27).
### 4. Fake Docker Host
The package supports a "fake" Docker host scheme (`agent://<addr>`) to identify containers managed by an agent, allowing the GoDoxy server to route requests appropriately. See [`IsDockerHostAgent`](agent/pkg/agent/config.go:90) and [`GetAgentAddrFromDockerHost`](agent/pkg/agent/config.go:94).
The package supports a "fake" Docker host scheme (`agent://<addr>`) to identify containers managed by an agent, allowing the GoDoxy server to route requests appropriately. See [`IsDockerHostAgent`](config.go:90) and [`GetAgentAddrFromDockerHost`](config.go:94).
## Usage Example

View File

@@ -811,7 +811,7 @@
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/homepage.FetchResult"
"$ref": "#/definitions/iconfetch.Result"
}
}
},
@@ -1698,7 +1698,7 @@
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/homepage.IconMetaSearch"
"$ref": "#/definitions/iconlist.IconMetaSearch"
}
}
},
@@ -4335,8 +4335,7 @@
"items": {
"$ref": "#/definitions/rules.Rule"
},
"x-nullable": false,
"x-omitempty": false
"x-nullable": true
},
"scheme": {
"type": "string",
@@ -5202,7 +5201,7 @@
"x-nullable": false,
"x-omitempty": false
},
"homepage.FetchResult": {
"iconfetch.Result": {
"type": "object",
"properties": {
"icon": {
@@ -5223,7 +5222,7 @@
"x-nullable": false,
"x-omitempty": false
},
"homepage.IconMetaSearch": {
"iconlist.IconMetaSearch": {
"type": "object",
"properties": {
"Dark": {
@@ -5252,7 +5251,7 @@
"x-omitempty": false
},
"Source": {
"$ref": "#/definitions/homepage.IconSource",
"$ref": "#/definitions/icons.Source",
"x-nullable": false,
"x-omitempty": false
},
@@ -5265,7 +5264,7 @@
"x-nullable": false,
"x-omitempty": false
},
"homepage.IconSource": {
"icons.Source": {
"type": "string",
"enum": [
"https://",
@@ -5274,10 +5273,10 @@
"@selfhst"
],
"x-enum-varnames": [
"IconSourceAbsolute",
"IconSourceRelative",
"IconSourceWalkXCode",
"IconSourceSelfhSt"
"SourceAbsolute",
"SourceRelative",
"SourceWalkXCode",
"SourceSelfhSt"
],
"x-nullable": false,
"x-omitempty": false
@@ -5515,8 +5514,7 @@
"items": {
"$ref": "#/definitions/rules.Rule"
},
"x-nullable": false,
"x-omitempty": false
"x-nullable": true
},
"scheme": {
"type": "string",

View File

@@ -959,6 +959,7 @@ definitions:
items:
$ref: '#/definitions/rules.Rule'
type: array
x-nullable: true
scheme:
enum:
- http
@@ -1428,7 +1429,7 @@ definitions:
required:
- id
type: object
homepage.FetchResult:
iconfetch.Result:
properties:
icon:
items:
@@ -1438,7 +1439,7 @@ definitions:
statusCode:
type: integer
type: object
homepage.IconMetaSearch:
iconlist.IconMetaSearch:
properties:
Dark:
type: boolean
@@ -1451,11 +1452,11 @@ definitions:
SVG:
type: boolean
Source:
$ref: '#/definitions/homepage.IconSource'
$ref: '#/definitions/icons.Source'
WebP:
type: boolean
type: object
homepage.IconSource:
icons.Source:
enum:
- https://
- '@target'
@@ -1463,10 +1464,10 @@ definitions:
- '@selfhst'
type: string
x-enum-varnames:
- IconSourceAbsolute
- IconSourceRelative
- IconSourceWalkXCode
- IconSourceSelfhSt
- SourceAbsolute
- SourceRelative
- SourceWalkXCode
- SourceSelfhSt
mem.VirtualMemoryStat:
properties:
available:
@@ -1594,6 +1595,7 @@ definitions:
items:
$ref: '#/definitions/rules.Rule'
type: array
x-nullable: true
scheme:
enum:
- http
@@ -2229,7 +2231,7 @@ paths:
description: OK
schema:
items:
$ref: '#/definitions/homepage.FetchResult'
$ref: '#/definitions/iconfetch.Result'
type: array
"400":
description: 'Bad Request: alias is empty or route is not HTTPRoute'
@@ -2811,7 +2813,7 @@ paths:
description: OK
schema:
items:
$ref: '#/definitions/homepage.IconMetaSearch'
$ref: '#/definitions/iconlist.IconMetaSearch'
type: array
"400":
description: Bad Request

View File

@@ -28,7 +28,7 @@ type GetFavIconRequest struct {
// @Produce image/svg+xml,image/x-icon,image/png,image/webp
// @Param url query string false "URL of the route"
// @Param alias query string false "Alias of the route"
// @Success 200 {array} homepage.FetchResult
// @Success 200 {array} iconfetch.Result
// @Failure 400 {object} apitypes.ErrorResponse "Bad Request: alias is empty or route is not HTTPRoute"
// @Failure 403 {object} apitypes.ErrorResponse "Forbidden: unauthorized"
// @Failure 404 {object} apitypes.ErrorResponse "Not Found: route or icon not found"

View File

@@ -22,7 +22,7 @@ type ListIconsRequest struct {
// @Produce json
// @Param limit query int false "Limit"
// @Param keyword query string false "Keyword"
// @Success 200 {array} homepage.IconMetaSearch
// @Success 200 {array} iconlist.IconMetaSearch
// @Failure 400 {object} apitypes.ErrorResponse
// @Failure 403 {object} apitypes.ErrorResponse
// @Router /icons [get]

View File

@@ -55,7 +55,7 @@ type (
route.HTTPConfig
PathPatterns []string `json:"path_patterns,omitempty" extensions:"x-nullable"`
Rules rules.Rules `json:"rules,omitempty" extension:"x-nullable"`
Rules rules.Rules `json:"rules,omitempty" extensions:"x-nullable"`
RuleFile string `json:"rule_file,omitempty" extensions:"x-nullable"`
HealthCheck types.HealthCheckConfig `json:"healthcheck,omitempty" extensions:"x-nullable"` // null on load-balancer routes
LoadBalance *types.LoadBalancerConfig `json:"load_balance,omitempty" extensions:"x-nullable"`

View File

@@ -15,6 +15,7 @@ var (
ErrInvalidArguments = gperr.New("invalid arguments")
ErrInvalidOnTarget = gperr.New("invalid `rule.on` target")
ErrInvalidCommandSequence = gperr.New("invalid command sequence")
ErrMultipleDefaultRules = gperr.New("multiple default rules")
// vars errors
ErrNoArgProvided = gperr.New("no argument provided")

View File

@@ -26,6 +26,7 @@ func (on *RuleOn) Check(w http.ResponseWriter, r *http.Request) bool {
}
const (
OnDefault = "default"
OnHeader = "header"
OnQuery = "query"
OnCookie = "cookie"
@@ -50,6 +51,22 @@ var checkers = map[string]struct {
builder func(args any) CheckFunc
isResponseChecker bool
}{
OnDefault: {
help: Help{
command: OnDefault,
description: makeLines(
"The default rule is matched when no other rules are matched.",
),
args: map[string]string{},
},
validate: func(args []string) (any, gperr.Error) {
if len(args) != 0 {
return nil, ErrExpectNoArg
}
return nil, nil
},
builder: func(args any) CheckFunc { return func(w http.ResponseWriter, r *http.Request) bool { return false } }, // this should never be called
},
OnHeader: {
help: Help{
command: OnHeader,

View File

@@ -7,6 +7,7 @@ import (
"github.com/quic-go/quic-go/http3"
"github.com/rs/zerolog/log"
gperr "github.com/yusing/goutils/errs"
httputils "github.com/yusing/goutils/http"
"golang.org/x/net/http2"
@@ -57,6 +58,19 @@ func (rule *Rule) IsResponseRule() bool {
return rule.On.IsResponseChecker() || rule.Do.IsResponseHandler()
}
func (rules Rules) Validate() gperr.Error {
var defaultRulesFound []int
for i, rule := range rules {
if rule.Name == "default" || rule.On.raw == OnDefault {
defaultRulesFound = append(defaultRulesFound, i)
}
}
if len(defaultRulesFound) > 1 {
return ErrMultipleDefaultRules.Withf("found %d", len(defaultRulesFound))
}
return nil
}
// BuildHandler returns a http.HandlerFunc that implements the rules.
func (rules Rules) BuildHandler(up http.HandlerFunc) http.HandlerFunc {
if len(rules) == 0 {
@@ -74,7 +88,7 @@ func (rules Rules) BuildHandler(up http.HandlerFunc) http.HandlerFunc {
var nonDefaultRules Rules
hasDefaultRule := false
for i, rule := range rules {
if rule.Name == "default" {
if rule.Name == "default" || rule.On.raw == OnDefault {
defaultRule = rule
hasDefaultRule = true
} else {

View File

@@ -0,0 +1,52 @@
package rules
import (
"reflect"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yusing/godoxy/internal/serialization"
)
func TestRulesValidate(t *testing.T) {
tests := []struct {
name string
rules string
want error
}{
{
name: "no default rule",
rules: `
- name: rule1
on: header Host example.com
do: pass
`,
},
{
name: "multiple default rules",
rules: `
- name: default
do: pass
- name: rule1
on: default
do: pass
`,
want: ErrMultipleDefaultRules,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var rules Rules
convertible, err := serialization.ConvertString(strings.TrimSpace(tt.rules), reflect.ValueOf(&rules))
require.True(t, convertible)
if tt.want == nil {
assert.NoError(t, err)
return
}
assert.ErrorIs(t, err, tt.want)
})
}
}

View File

@@ -449,51 +449,66 @@ func Convert(src reflect.Value, dst reflect.Value, checkValidateTag bool) gperr.
}
return mapUnmarshalValidate(obj, dst.Addr(), checkValidateTag)
case srcKind == reflect.Slice: // slice to slice
srcLen := src.Len()
if srcLen == 0 {
dst.SetZero()
return nil
}
if dstT.Kind() != reflect.Slice {
return ErrUnsupportedConversion.Subject(dstT.String() + " to " + srcT.String())
}
var sliceErrs gperr.Builder
i := 0
gi.ReflectInitSlice(dst, srcLen, srcLen)
for j, v := range src.Seq2() {
err := Convert(v, dst.Index(i), checkValidateTag)
if err != nil {
sliceErrs.Add(err.Subjectf("[%d]", j))
continue
}
i++
}
if err := sliceErrs.Error(); err != nil {
dst.SetLen(i) // shrink to number of elements that were successfully converted
return err
}
return nil
return ConvertSlice(src, dst, checkValidateTag)
}
return ErrUnsupportedConversion.Subjectf("%s to %s", srcT.String(), dstT.String())
return ErrUnsupportedConversion.Subjectf("%s to %s", srcT, dstT)
}
var parserType = reflect.TypeFor[strutils.Parser]()
func ConvertSlice(src reflect.Value, dst reflect.Value, checkValidateTag bool) gperr.Error {
if src.Kind() != reflect.Slice {
return Convert(src, dst, checkValidateTag)
}
srcLen := src.Len()
if srcLen == 0 {
dst.SetZero()
return nil
}
if dst.Kind() != reflect.Slice {
return ErrUnsupportedConversion.Subjectf("%s to %s", dst.Type(), src.Type())
}
var sliceErrs gperr.Builder
numValid := 0
gi.ReflectInitSlice(dst, srcLen, srcLen)
for j := range srcLen {
err := Convert(src.Index(j), dst.Index(numValid), checkValidateTag)
if err != nil {
sliceErrs.Add(err.Subjectf("[%d]", j))
continue
}
numValid++
}
if dst.Type().Implements(reflect.TypeFor[CustomValidator]()) {
err := dst.Interface().(CustomValidator).Validate()
if err != nil {
sliceErrs.Add(err)
}
}
if err := sliceErrs.Error(); err != nil {
dst.SetLen(numValid) // shrink to number of elements that were successfully converted
return err
}
return nil
}
func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gperr.Error) {
convertible = true
dstT := dst.Type()
if dst.Kind() == reflect.Pointer {
if dst.IsNil() {
// Early return for empty string
if src == "" {
return true, nil
}
initPtr(dst)
}
dst = dst.Elem()
dstT = dst.Type()
}
if dst.Kind() == reflect.String {
dst.SetString(src)
return true, nil
}
// Early return for empty string
if src == "" {
@@ -501,6 +516,17 @@ func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gpe
return true, nil
}
if dst.Kind() == reflect.String {
dst.SetString(src)
return true, nil
}
// check if (*T).Convertor is implemented
if addr := dst.Addr(); addr.Type().Implements(reflect.TypeFor[strutils.Parser]()) {
parser := addr.Interface().(strutils.Parser)
return true, gperr.Wrap(parser.Parse(src))
}
switch dstT {
case reflect.TypeFor[time.Duration]():
d, err := time.ParseDuration(src)
@@ -512,12 +538,6 @@ func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gpe
default:
}
// check if (*T).Convertor is implemented
if dst.Addr().Type().Implements(parserType) {
parser := dst.Addr().Interface().(strutils.Parser)
return true, gperr.Wrap(parser.Parse(src))
}
if gi.ReflectIsNumeric(dst) || dst.Kind() == reflect.Bool {
err := gi.ReflectStrToNumBool(dst, src)
if err != nil {
@@ -527,29 +547,25 @@ func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gpe
}
// yaml like
var tmp any
switch dst.Kind() {
case reflect.Slice:
// Avoid unnecessary TrimSpace if we can detect the format early
srcLen := len(src)
if srcLen == 0 {
return true, nil
}
// one liner is comma separated list
isMultiline := strings.ContainsRune(src, '\n')
if !isMultiline && src[0] != '-' {
isMultiline := strings.IndexByte(src, '\n') != -1
if !isMultiline && src[0] != '-' && src[0] != '[' {
values := strutils.CommaSeperatedList(src)
gi.ReflectInitSlice(dst, len(values), len(values))
size := len(values)
gi.ReflectInitSlice(dst, size, size)
var errs gperr.Builder
for i, v := range values {
_, err := ConvertString(v, dst.Index(i))
if err != nil {
errs.Add(err.Subjectf("[%d]", i))
errs.AddSubjectf(err, "[%d]", i)
}
}
err := errs.Error()
return true, err
if errs.HasError() {
return true, errs.Error()
}
return true, nil
}
sl := []any{}
@@ -557,18 +573,17 @@ func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gpe
if err != nil {
return true, gperr.Wrap(err)
}
tmp = sl
return true, ConvertSlice(reflect.ValueOf(sl), dst, true)
case reflect.Map, reflect.Struct:
rawMap := SerializedObject{}
err := yaml.Unmarshal(unsafe.Slice(unsafe.StringData(src), len(src)), &rawMap)
if err != nil {
return true, gperr.Wrap(err)
}
tmp = rawMap
return true, mapUnmarshalValidate(rawMap, dst, true)
default:
return false, nil
}
return true, Convert(reflect.ValueOf(tmp), dst, true)
}
var envRegex = regexp.MustCompile(`\$\{([^}]+)\}`) // e.g. ${CLOUDFLARE_API_KEY}

View File

@@ -32,9 +32,9 @@ func BenchmarkDeserialize(b *testing.B) {
"c": "1,2,3",
"d": "a: a\nb: b\nc: c",
"e": "- a: a\n b: b\n c: c",
"f": map[string]any{"a": "a", "b": "456", "c": []string{"1", "2", "3"}},
"f": map[string]any{"a": "a", "b": "456", "c": `1,2,3`},
"g": map[string]any{"g1": "1.23", "g2": 123},
"h": []map[string]any{{"a": 123, "b": "456", "c": []string{"1", "2", "3"}}},
"h": []map[string]any{{"a": 123, "b": "456", "c": `["1","2","3"]`}},
"j": "1.23",
"k": 123,
}

View File

@@ -1,5 +1,4 @@
import { Glob } from "bun";
import { linkSync } from "fs";
import { mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
import path from "path";
@@ -8,6 +7,8 @@ type ImplDoc = {
pkgPath: string;
/** File name in wiki `src/impl/`, e.g. "internal-health-check.md" */
docFileName: string;
/** VitePress route path (extensionless), e.g. "/impl/internal-health-check" */
docRoute: string;
/** Absolute source README path */
srcPathAbs: string;
/** Absolute destination doc path */
@@ -17,6 +18,8 @@ type ImplDoc = {
const START_MARKER = "// GENERATED-IMPL-SIDEBAR-START";
const END_MARKER = "// GENERATED-IMPL-SIDEBAR-END";
const skipSubmodules = ["internal/go-oidc/", "internal/gopsutil/"];
function escapeRegex(s: string) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
@@ -25,6 +28,16 @@ function escapeSingleQuotedTs(s: string) {
return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
}
function normalizeRepoUrl(raw: string) {
let url = (raw ?? "").trim();
if (!url) return "";
// Common typo: "https://https://github.com/..."
url = url.replace(/^https?:\/\/https?:\/\//i, "https://");
if (!/^https?:\/\//i.test(url)) url = `https://${url}`;
url = url.replace(/\/+$/, "");
return url;
}
function sanitizeFileStemFromPkgPath(pkgPath: string) {
// Convert a package path into a stable filename.
// Example: "internal/go-oidc/example" -> "internal-go-oidc-example"
@@ -37,6 +50,133 @@ function sanitizeFileStemFromPkgPath(pkgPath: string) {
return joined.replace(/-+/g, "-").replace(/^-|-$/g, "");
}
function splitUrlAndFragment(url: string): {
urlNoFragment: string;
fragment: string;
} {
const i = url.indexOf("#");
if (i === -1) return { urlNoFragment: url, fragment: "" };
return { urlNoFragment: url.slice(0, i), fragment: url.slice(i) };
}
function isExternalOrAbsoluteUrl(url: string) {
// - absolute site links: "/foo"
// - pure fragments: "#bar"
// - external schemes: "https:", "mailto:", "vscode:", etc.
// IMPORTANT: don't treat "config.go:29" as a scheme.
if (url.startsWith("/") || url.startsWith("#")) return true;
if (url.includes("://")) return true;
return /^(https?|mailto|tel|vscode|file|data|ssh|git):/i.test(url);
}
function isRepoSourceFilePath(filePath: string) {
// Conservative allow-list: avoid rewriting .md (non-README) which may be VitePress docs.
return /\.(go|ts|tsx|js|jsx|py|sh|yml|yaml|json|toml|env|css|html|txt)$/i.test(
filePath
);
}
function parseFileLineSuffix(urlNoFragment: string): {
filePath: string;
line?: string;
} {
// Match "file.ext:123" (line suffix), while leaving "file.ext" untouched.
const m = urlNoFragment.match(/^(.*?):(\d+)$/);
if (!m) return { filePath: urlNoFragment };
return { filePath: m[1] ?? urlNoFragment, line: m[2] };
}
function rewriteMarkdownLinksOutsideFences(
md: string,
rewriteInline: (url: string) => string
) {
const lines = md.split("\n");
let inFence = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? "";
const trimmed = line.trimStart();
if (trimmed.startsWith("```")) {
inFence = !inFence;
continue;
}
if (inFence) continue;
// Inline markdown links/images: [text](url "title") / ![alt](url)
lines[i] = line.replace(
/\]\(([^)\s]+)(\s+"[^"]*")?\)/g,
(_full, urlRaw: string, maybeTitle: string | undefined) => {
const rewritten = rewriteInline(urlRaw);
return `](${rewritten}${maybeTitle ?? ""})`;
}
);
}
return lines.join("\n");
}
function rewriteImplMarkdown(params: {
md: string;
pkgPath: string;
readmeRelToDocRoute: Map<string, string>;
dirPathToDocRoute: Map<string, string>;
repoUrl: string;
}) {
const { md, pkgPath, readmeRelToDocRoute, dirPathToDocRoute, repoUrl } =
params;
return rewriteMarkdownLinksOutsideFences(md, (urlRaw) => {
// Handle angle-bracketed destinations: (<./foo/README.md>)
const angleWrapped =
urlRaw.startsWith("<") && urlRaw.endsWith(">")
? urlRaw.slice(1, -1)
: urlRaw;
const { urlNoFragment, fragment } = splitUrlAndFragment(angleWrapped);
if (!urlNoFragment) return urlRaw;
if (isExternalOrAbsoluteUrl(urlNoFragment)) return urlRaw;
// 1) Directory links like "common" or "common/" that have a README
const dirPathNormalized = urlNoFragment.replace(/\/+$/, "");
if (dirPathToDocRoute.has(dirPathNormalized)) {
const rewritten = `${dirPathToDocRoute.get(
dirPathNormalized
)!}${fragment}`;
return angleWrapped === urlRaw ? rewritten : `<${rewritten}>`;
}
// 2) Intra-repo README links -> VitePress impl routes
if (/(^|\/)README\.md$/.test(urlNoFragment)) {
const targetReadmeRel = path.posix.normalize(
path.posix.join(pkgPath, urlNoFragment)
);
const route = readmeRelToDocRoute.get(targetReadmeRel);
if (route) {
const rewritten = `${route}${fragment}`;
return angleWrapped === urlRaw ? rewritten : `<${rewritten}>`;
}
return urlRaw;
}
// 3) Local source-file references like "config.go:29" -> GitHub blob link
if (repoUrl) {
const { filePath, line } = parseFileLineSuffix(urlNoFragment);
if (isRepoSourceFilePath(filePath)) {
const repoRel = path.posix.normalize(
path.posix.join(pkgPath, filePath)
);
const githubUrl = `${repoUrl}/blob/main/${repoRel}${
line ? `#L${line}` : ""
}`;
const rewritten = `${githubUrl}${fragment}`;
return angleWrapped === urlRaw ? rewritten : `<${rewritten}>`;
}
}
return urlRaw;
});
}
async function listRepoReadmes(repoRootAbs: string): Promise<string[]> {
const glob = new Glob("**/README.md");
const readmes: string[] = [];
@@ -51,8 +191,14 @@ async function listRepoReadmes(repoRootAbs: string): Promise<string[]> {
if (rel.startsWith(".git/") || rel.includes("/.git/")) continue;
if (rel.startsWith("node_modules/") || rel.includes("/node_modules/"))
continue;
if (rel.startsWith("internal/go-oidc/")) continue;
if (rel.startsWith("internal/gopsutil/")) continue;
let skip = false;
for (const submodule of skipSubmodules) {
if (rel.startsWith(submodule)) {
skip = true;
break;
}
}
if (skip) continue;
readmes.push(rel);
}
@@ -61,11 +207,34 @@ async function listRepoReadmes(repoRootAbs: string): Promise<string[]> {
return readmes;
}
async function ensureHardLink(srcAbs: string, dstAbs: string) {
async function writeImplDocCopy(params: {
srcAbs: string;
dstAbs: string;
pkgPath: string;
readmeRelToDocRoute: Map<string, string>;
dirPathToDocRoute: Map<string, string>;
repoUrl: string;
}) {
const {
srcAbs,
dstAbs,
pkgPath,
readmeRelToDocRoute,
dirPathToDocRoute,
repoUrl,
} = params;
await mkdir(path.dirname(dstAbs), { recursive: true });
await rm(dstAbs, { force: true });
// Prefer sync for better error surfaces in Bun on some platforms.
linkSync(srcAbs, dstAbs);
const original = await readFile(srcAbs, "utf8");
const rewritten = rewriteImplMarkdown({
md: original,
pkgPath,
readmeRelToDocRoute,
dirPathToDocRoute,
repoUrl,
});
await writeFile(dstAbs, rewritten);
}
async function syncImplDocs(
@@ -78,6 +247,30 @@ async function syncImplDocs(
const readmes = await listRepoReadmes(repoRootAbs);
const docs: ImplDoc[] = [];
const expectedFileNames = new Set<string>();
expectedFileNames.add("introduction.md");
const repoUrl = normalizeRepoUrl(
Bun.env.REPO_URL ?? "https://github.com/yusing/godoxy"
);
// Precompute mapping from repo-relative README path -> VitePress route.
// This lets us rewrite intra-repo README links when copying content.
const readmeRelToDocRoute = new Map<string, string>();
// Also precompute mapping from directory path -> VitePress route.
// This handles links like "[`common/`](common)" that point to directories with READMEs.
const dirPathToDocRoute = new Map<string, string>();
for (const readmeRel of readmes) {
const pkgPath = path.posix.dirname(readmeRel);
if (!pkgPath || pkgPath === ".") continue;
const docStem = sanitizeFileStemFromPkgPath(pkgPath);
if (!docStem) continue;
const route = `/impl/${docStem}`;
readmeRelToDocRoute.set(readmeRel, route);
dirPathToDocRoute.set(pkgPath, route);
}
for (const readmeRel of readmes) {
const pkgPath = path.posix.dirname(readmeRel);
@@ -86,13 +279,21 @@ async function syncImplDocs(
const docStem = sanitizeFileStemFromPkgPath(pkgPath);
if (!docStem) continue;
const docFileName = `${docStem}.md`;
const docRoute = `/impl/${docStem}`;
const srcPathAbs = path.join(repoRootAbs, readmeRel);
const dstPathAbs = path.join(implDirAbs, docFileName);
await ensureHardLink(srcPathAbs, dstPathAbs);
await writeImplDocCopy({
srcAbs: srcPathAbs,
dstAbs: dstPathAbs,
pkgPath,
readmeRelToDocRoute,
dirPathToDocRoute,
repoUrl,
});
docs.push({ pkgPath, docFileName, srcPathAbs, dstPathAbs });
docs.push({ pkgPath, docFileName, docRoute, srcPathAbs, dstPathAbs });
expectedFileNames.add(docFileName);
}
@@ -111,13 +312,13 @@ async function syncImplDocs(
}
function renderSidebarItems(docs: ImplDoc[], indent: string) {
// link: '/impl/<file>.md' because VitePress `srcDir = "src"`.
// link: '/impl/<stem>' (extensionless) because VitePress `srcDir = "src"`.
if (docs.length === 0) return "";
return (
docs
.map((d) => {
const text = escapeSingleQuotedTs(d.pkgPath);
const link = escapeSingleQuotedTs(`/impl/${d.docFileName}`);
const link = escapeSingleQuotedTs(d.docRoute);
return `${indent}{ text: '${text}', link: '${link}' },`;
})
.join("\n") + "\n"