docs
packageAPI reference for the docs
package.
Imports
(18)encoding/json
STD
errors
STD
fmt
STD
io
STD
io/fs
STD
os
STD
path/filepath
STD
strings
INT
github.com/rfwlab/rfw/v2/cmd/rfw/plugins
STD
testing
STD
regexp
STD
time
INT
github.com/rfwlab/rfw/v2/core
INT
github.com/rfwlab/rfw/v2/events
INT
github.com/rfwlab/rfw/v2/js
INT
github.com/rfwlab/rfw/v2/markdown
INT
github.com/rfwlab/rfw/v2/plugins/seo
INT
github.com/rfwlab/rfw/v2/state
plugin
type plugin struct
Methods
Parameters
Returns
func (*plugin) Build(raw json.RawMessage) (err error)
{
cfg := struct {
Dir string `json:"dir"`
Dest string `json:"dest"`
}{
Dir: "articles",
Dest: filepath.Join("build", "static"),
}
if len(raw) > 0 {
if err := json.Unmarshal(raw, &cfg); err != nil {
return fmt.Errorf("decode docs plugin config: %w", err)
}
}
sourcePath, err := docsProjectDirectory(cfg.Dir)
if err != nil {
return fmt.Errorf("invalid docs source: %w", err)
}
destinationPath, err := docsProjectDirectory(cfg.Dest)
if err != nil {
return fmt.Errorf("invalid docs destination: %w", err)
}
p.src = sourcePath
base := filepath.Base(sourcePath)
destRoot := filepath.Join(destinationPath, base)
projectRoot, err := os.OpenRoot(".")
if err != nil {
return err
}
defer func() {
if closeErr := projectRoot.Close(); err == nil {
err = closeErr
}
}()
if err := projectRoot.MkdirAll(destRoot, 0o755); err != nil {
return err
}
sourceRoot, err := projectRoot.OpenRoot(sourcePath)
if err != nil {
return err
}
defer func() {
if closeErr := sourceRoot.Close(); err == nil {
err = closeErr
}
}()
targetRoot, err := projectRoot.OpenRoot(destRoot)
if err != nil {
return err
}
defer func() {
if closeErr := targetRoot.Close(); err == nil {
err = closeErr
}
}()
err = fs.WalkDir(sourceRoot.FS(), ".", func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
if path == "." {
return nil
}
if err := targetRoot.MkdirAll(path, 0o755); err != nil {
return err
}
return nil
}
in, err := sourceRoot.Open(path)
if err != nil {
return err
}
if err := targetRoot.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return errors.Join(err, in.Close())
}
out, err := targetRoot.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if err != nil {
return errors.Join(err, in.Close())
}
_, copyErr := io.Copy(out, in)
closeErr := errors.Join(out.Close(), in.Close())
return errors.Join(copyErr, closeErr)
})
return err
}
Parameters
Returns
func (*plugin) ShouldRebuild(path string) bool
{
relative, err := filepath.Rel(p.src, path)
return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
}
Fields
| Name | Type | Description |
|---|---|---|
| src | string |
init
func init()
{ plugins.Register(&plugin{}) }
docsProjectDirectory
Parameters
Returns
func docsProjectDirectory(path string) (string, error)
{
if path == "" || filepath.IsAbs(path) {
return "", fmt.Errorf("path %q must be project-relative", path)
}
cleaned := filepath.Clean(path)
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("path %q escapes the project", path)
}
return cleaned, nil
}
TestBuildAndShouldRebuild
TestBuildAndShouldRebuild ensures the docs plugin copies files from the
source directory to the destination and correctly reports rebuild needs.
Parameters
func TestBuildAndShouldRebuild(t *testing.T)
{
p := &plugin{}
tmp := t.TempDir()
t.Chdir(tmp)
src := "articles"
dest := "out"
if err := os.MkdirAll(filepath.Join(src, "a"), 0o750); err != nil {
t.Fatalf("mkdir: %v", err)
}
srcFile := filepath.Join(src, "a", "doc.txt")
if err := os.WriteFile(srcFile, []byte("hello"), 0o600); err != nil {
t.Fatalf("write src: %v", err)
}
cfg := struct {
Dir string `json:"dir"`
Dest string `json:"dest"`
}{Dir: src, Dest: dest}
raw, err := json.Marshal(cfg)
if err != nil {
t.Fatal(err)
}
if err := p.Build(raw); err != nil {
t.Fatalf("Build: %v", err)
}
// File should be copied under dest/<basename>/a/doc.txt
if data, err := os.ReadFile("out/articles/a/doc.txt"); err != nil || string(data) != "hello" {
t.Fatalf("expected copied file, got %v %q", err, data)
}
if !p.ShouldRebuild(srcFile) {
t.Fatalf("expected ShouldRebuild true for %s", srcFile)
}
if p.ShouldRebuild(filepath.Join(tmp, "other.txt")) {
t.Fatalf("unexpected rebuild for unrelated file")
}
}
TestBuildRejectsPathsOutsideProject
Parameters
func TestBuildRejectsPathsOutsideProject(t *testing.T)
{
p := &plugin{}
raw, err := json.Marshal(struct {
Dir string `json:"dir"`
Dest string `json:"dest"`
}{Dir: "../articles", Dest: "build/static"})
if err != nil {
t.Fatal(err)
}
if err := p.Build(raw); err == nil {
t.Fatal("expected source outside project to be rejected")
}
}
slugger
type slugger struct
Methods
Parameters
Returns
func (*slugger) slug(text string) string
{
slug := strings.ToLower(text)
slug = slugRe.ReplaceAllString(slug, "")
slug = strings.TrimSpace(slug)
slug = strings.ReplaceAll(slug, " ", "-")
if n, ok := s.seen[slug]; ok {
s.seen[slug] = n + 1
return fmt.Sprintf("%s-%d", slug, n)
}
s.seen[slug] = 1
return slug
}
Parameters
Returns
func (*slugger) slug(text string) string
{
slug := strings.ToLower(text)
slug = slugRe.ReplaceAllString(slug, "")
slug = strings.TrimSpace(slug)
slug = strings.ReplaceAll(slug, " ", "-")
if n, ok := s.seen[slug]; ok {
s.seen[slug] = n + 1
return fmt.Sprintf("%s-%d", slug, n)
}
s.seen[slug] = 1
return slug
}
Fields
| Name | Type | Description |
|---|---|---|
| seen | map[string]int |
newSlugger
Returns
func newSlugger() *slugger
{ return &slugger{seen: make(map[string]int)} }
slugger
type slugger struct
Methods
Parameters
Returns
func (*slugger) slug(text string) string
{
slug := strings.ToLower(text)
slug = slugRe.ReplaceAllString(slug, "")
slug = strings.TrimSpace(slug)
slug = strings.ReplaceAll(slug, " ", "-")
if n, ok := s.seen[slug]; ok {
s.seen[slug] = n + 1
return fmt.Sprintf("%s-%d", slug, n)
}
s.seen[slug] = 1
return slug
}
Parameters
Returns
func (*slugger) slug(text string) string
{
slug := strings.ToLower(text)
slug = slugRe.ReplaceAllString(slug, "")
slug = strings.TrimSpace(slug)
slug = strings.ReplaceAll(slug, " ", "-")
if n, ok := s.seen[slug]; ok {
s.seen[slug] = n + 1
return fmt.Sprintf("%s-%d", slug, n)
}
s.seen[slug] = 1
return slug
}
Fields
| Name | Type | Description |
|---|---|---|
| seen | map[string]int |
newSlugger
Returns
func newSlugger() *slugger
{ return &slugger{seen: make(map[string]int)} }
SidebarItem
SidebarItem describes one documentation navigation entry.
type SidebarItem struct
Fields
| Name | Type | Description |
|---|---|---|
| Title | string | json:"title" |
| Path | string | json:"path" |
| Description | string | json:"description" |
| Children | []SidebarItem | json:"children" |
ArticleData
ArticleData contains a loaded documentation article.
type ArticleData struct
Fields
| Name | Type | Description |
|---|---|---|
| Path | string | |
| Content | string | |
| Headings | []Heading |
Heading
Heading describes one article heading.
type Heading struct
Fields
| Name | Type | Description |
|---|---|---|
| Text | string | |
| Depth | int | |
| ID | string |
Plugin
Plugin loads documentation navigation and article content.
type Plugin struct
Methods
Name returns the plugin name.
Returns
func (*Plugin) Name() string
{ return "docs" }
Optional declares the SEO plugin when metadata support is enabled.
Returns
func (*Plugin) Optional() []core.Plugin
{
if p.disableSEO {
return nil
}
return []core.Plugin{seo.New()}
}
Provide returns the values exposed to component templates.
Returns
func (*Plugin) Provide() map[string]any
{
return map[string]any{
"sidebar": p.sidebarData,
"article": p.articleData,
"loadDoc": p.loadArticle,
}
}
Install loads the sidebar and exposes the article loader.
Parameters
func (*Plugin) Install(_ *core.App)
{
for k, v := range p.Provide() {
core.RegisterPluginVar("docs", k, v)
}
doc := js.Document()
js.Fetch(p.Sidebar).Call("then", js.SafeFuncOf(func(_ js.Value, args []js.Value) any {
res := args[0]
res.Call("text").Call("then", js.SafeFuncOf(func(_ js.Value, args []js.Value) any {
raw := args[0].String()
var items []SidebarItem
if err := json.Unmarshal([]byte(raw), &items); err == nil {
p.sidebarData.Set(items)
}
js.Set("__rfwDocsSidebar", raw)
doc.Call("dispatchEvent", js.CustomEvent().New("rfwSidebar"))
events.EmitApp(events.EventSidebarLoaded, items)
return nil
}))
return nil
}))
p.loader = js.SafeFuncOf(func(_ js.Value, args []js.Value) any {
if len(args) < 1 {
return nil
}
p.loadArticle(args[0].String())
return nil
})
js.Set("rfwLoadDoc", p.loader)
}
Parameters
func (*Plugin) loadArticle(path string)
{
js.Fetch(path).Call("then", js.SafeFuncOf(func(_ js.Value, args []js.Value) any {
res := args[0]
res.Call("text").Call("then", js.SafeFuncOf(func(_ js.Value, args []js.Value) any {
content := args[0].String()
mhs := markdown.Headings(content)
headings := make([]Heading, len(mhs))
for i, h := range mhs {
headings[i] = Heading{Text: h.Text, Depth: h.Depth, ID: h.ID}
}
data := &ArticleData{
Path: path,
Content: content,
Headings: headings,
}
p.articleData.Set(data)
doc := js.Document()
doc.Call("dispatchEvent", js.CustomEvent().New("rfwDoc", map[string]any{
"detail": map[string]any{
"path": path,
"content": content,
"headings": headingsToAny(headings),
},
}))
events.EmitApp(events.EventArticleLoaded, data)
return nil
}))
return nil
}))
}
Build accepts the plugin build configuration.
Parameters
Returns
func (*Plugin) Build(json.RawMessage) error
{ return nil }
Fields
| Name | Type | Description |
|---|---|---|
| Sidebar | string | |
| disableSEO | bool | |
| loader | js.Func | |
| sidebarData | *state.Signal[[]SidebarItem] | |
| articleData | *state.Signal[*ArticleData] |
New
New creates a documentation plugin using the supplied sidebar URL.
Parameters
Returns
func New(sidebar string, disableSEO ...bool) *Plugin
{
sidebar = fmt.Sprintf("%s?%d", sidebar, time.Now().Unix())
p := &Plugin{Sidebar: sidebar}
if len(disableSEO) > 0 {
p.disableSEO = disableSEO[0]
}
p.sidebarData = state.NewSignal[[]SidebarItem](nil)
p.articleData = state.NewSignal[*ArticleData](nil)
return p
}
headingsToAny
Parameters
Returns
func headingsToAny(headings []Heading) []any
{
result := make([]any, len(headings))
for i, h := range headings {
result[i] = map[string]any{
"text": h.Text,
"depth": h.Depth,
"id": h.ID,
}
}
return result
}
TestSlugger
TestSlugger ensures slug generation is deterministic and handles duplicates.
Parameters
func TestSlugger(t *testing.T)
{
s := newSlugger()
first := s.slug("Hello World!")
if first != "hello-world" {
t.Fatalf("expected 'hello-world', got %q", first)
}
second := s.slug("Hello World!")
if second != "hello-world-1" {
t.Fatalf("expected 'hello-world-1', got %q", second)
}
}
LoadArticle
LoadArticle fetches and renders the markdown document at the given path.
It relies on the rfwLoadDoc loader injected by the docs plugin and should
be used instead of direct js.Call invocations.
Parameters
func LoadArticle(path string)
{
js.Call("rfwLoadDoc", path)
}
LoadArticle
LoadArticle is a no-op when not running in a js/wasm environment.
Parameters
func LoadArticle(string)
{}
TestPluginProviderAndOptional
Parameters
func TestPluginProviderAndOptional(t *testing.T)
{
p := New("/sidebar.json")
provided := p.Provide()
if _, ok := provided["sidebar"].(*state.Signal[[]SidebarItem]); !ok {
t.Fatalf("expected sidebar signal provider, got %T", provided["sidebar"])
}
if _, ok := provided["article"].(*state.Signal[*ArticleData]); !ok {
t.Fatalf("expected article signal provider, got %T", provided["article"])
}
if _, ok := provided["loadDoc"].(func(string)); !ok {
t.Fatalf("expected loadDoc function provider, got %T", provided["loadDoc"])
}
if len(p.Optional()) != 1 {
t.Fatalf("expected SEO optional plugin by default")
}
withoutSEO := New("/sidebar.json", true)
if optional := withoutSEO.Optional(); optional != nil {
t.Fatalf("expected nil optional plugins when SEO disabled, got %v", optional)
}
}
TestHeadingsToAny
Parameters
func TestHeadingsToAny(t *testing.T)
{
out := headingsToAny([]Heading{{Text: "Intro", Depth: 2, ID: "intro"}})
if len(out) != 1 {
t.Fatalf("expected one heading, got %d", len(out))
}
m, ok := out[0].(map[string]any)
if !ok {
t.Fatalf("expected map heading, got %T", out[0])
}
if m["text"] != "Intro" || m["depth"] != 2 || m["id"] != "intro" {
t.Fatalf("unexpected heading map: %v", m)
}
}