diff --git a/printingpress/aggregate_content_pages.go b/printingpress/aggregate_content_pages.go new file mode 100644 index 0000000..c5bcdea --- /dev/null +++ b/printingpress/aggregate_content_pages.go @@ -0,0 +1,31 @@ +// Copyright 2024-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// https://pb33f.io +// SPDX-License-Identifier: Apache-2.0 + +package printingpress + +import ( + "strings" + + ppmodel "github.com/pb33f/doctor/printingpress/model" +) + +func (ap *AggregatePrintingPress) collectCatalogContentPages() []*ppmodel.ContentPage { + if ap == nil || ap.config == nil { + return nil + } + root := strings.TrimSpace(ap.config.ScanRoot) + if root == "" { + return nil + } + pp := &PrintingPress{ + engineConfig: &pressEngineConfig{ + ContentDiscoveryEnabled: true, + ContentBasePath: root, + Logger: ap.config.Logger, + }, + site: &ppmodel.Site{}, + } + pp.collectContentPages() + return pp.site.ContentPages +} diff --git a/printingpress/aggregate_discovery.go b/printingpress/aggregate_discovery.go index e99a511..30c3ad4 100644 --- a/printingpress/aggregate_discovery.go +++ b/printingpress/aggregate_discovery.go @@ -112,6 +112,7 @@ func (ap *AggregatePrintingPress) buildPlan() (*aggregateBuildPlan, error) { } plan.removed = aggregateRemovedRecords(existing, discovered) plan.catalog = ap.buildCatalog(discovered) + plan.catalog.ContentPages = ap.collectCatalogContentPages() for _, spec := range discovered { if spec.Changed || ap.config.BuildMode == AggregateBuildModeFull { plan.changed = append(plan.changed, spec) diff --git a/printingpress/aggregate_test.go b/printingpress/aggregate_test.go index 2b19c77..e8fb696 100644 --- a/printingpress/aggregate_test.go +++ b/printingpress/aggregate_test.go @@ -7,6 +7,7 @@ package printingpress import ( "context" "database/sql" + "io" "os" "path/filepath" "strconv" @@ -14,7 +15,9 @@ import ( "testing" "time" + "github.com/a-h/templ" drV3 "github.com/pb33f/doctor/model/high/v3" + "github.com/pb33f/doctor/printingpress/internal/pppaths" ppmodel "github.com/pb33f/doctor/printingpress/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -169,6 +172,120 @@ func TestAggregatePrintingPress_PrintHTML_RendersCatalogAndEntrySites(t *testing assert.NotContains(t, string(entryHTML), `data-pp-versions-href=`) } +func TestAggregatePrintingPress_PrintHTML_RendersCatalogContentAndNav(t *testing.T) { + root := t.TempDir() + writeAggregateSpecWithDetails(t, root, "services/users/specs/users-public.yaml", "Users Public API", "Public user lifecycle endpoints.", "", "v1") + writeAggregateSpecWithDetails(t, root, "services/users/specs/users-admin.yaml", "Users Admin API", "Admin user lifecycle endpoints.", "", "v1") + writeFile(t, filepath.Join(root, "services", "users", "specs", "about.md"), "Service-local content should not render in aggregate entries.\n") + writeFile(t, filepath.Join(root, "_partials", "catalog-note.md"), "Shared catalog note.\n") + writeFile(t, filepath.Join(root, "images", "map.svg"), ``) + writeFile(t, filepath.Join(root, "about.md"), `--- +title: Catalog About +label: About +description: Higher-level catalog context. +--- +{{}} + +![Map](images/map.svg) + +See the [setup guide](docs/guide.md). +`) + writeFile(t, filepath.Join(root, "docs", "guide.md"), `--- +title: Catalog Setup +label: Setup +slug: guides/setup +description: How teams use the API catalog. +--- +Back to [about](../about.md). +`) + writeFile(t, filepath.Join(root, "faq.md"), `--- +title: Private FAQ +hidden: true +--- +Hidden direct page. +`) + + outputDir := filepath.Join(root, "site") + store := NewMemorySpecStateStore() + ap, err := CreateAggregatePrintingPressFromPath(root, &AggregatePrintingPressConfig{ + OutputDir: outputDir, + BuildMode: AggregateBuildModeFull, + StateStore: store, + Title: "Platform Catalog", + }) + require.NoError(t, err) + + catalog, err := ap.PressModel() + require.NoError(t, err) + require.Len(t, catalog.ContentPages, 3) + + _, err = ap.PrintHTML() + require.NoError(t, err) + + rootHTML := readAggregateFile(t, filepath.Join(outputDir, "index.html")) + assert.Contains(t, rootHTML, ``) + assert.Contains(t, rootHTML, ``) + assert.Contains(t, rootHTML, `class="pp-nav-preview"`) + rootNav := catalogNavSegment(rootHTML) + assert.Contains(t, rootNav, `class="nav-home active"`) + assert.Contains(t, rootNav, `>API Catalog`) + assert.Contains(t, rootNav, `

Guides

`) + assert.Contains(t, rootNav, `href="about.html"`) + assert.Contains(t, rootNav, `href="guides/setup.html"`) + assert.Contains(t, rootNav, `class="nav-page-link"`) + assert.Contains(t, rootNav, `class="nav-page-chevron"`) + assert.NotContains(t, rootNav, `faq.html`) + assert.NotContains(t, rootNav, `Users Public API`) + + guidesHTML := readAggregateFile(t, filepath.Join(outputDir, "guides.html")) + assert.Contains(t, guidesHTML, ``) + assert.Contains(t, guidesHTML, `href="about.html" class="pp-guide-card"`) + assert.Contains(t, guidesHTML, `href="guides/setup.html" class="pp-guide-card"`) + assert.NotContains(t, guidesHTML, `faq.html`) + + aboutHTML := readAggregateFile(t, filepath.Join(outputDir, "about.html")) + assert.Contains(t, aboutHTML, `GUIDES`) + assert.Contains(t, aboutHTML, "Shared catalog note.") + assert.Contains(t, aboutHTML, `src="assets/docs/about/map.svg"`) + assert.Contains(t, aboutHTML, `href="guides/setup.html"`) + assert.FileExists(t, filepath.Join(outputDir, "assets", "docs", "about", "map.svg")) + + setupHTML := readAggregateFile(t, filepath.Join(outputDir, "guides", "setup.html")) + assert.Contains(t, setupHTML, `HOME`) + assert.Contains(t, setupHTML, `GUIDES`) + assert.Contains(t, setupHTML, `href="../about.html"`) + assert.Contains(t, catalogNavSegment(setupHTML), `href="../index.html"`) + assert.Contains(t, catalogNavSegment(setupHTML), `class="nav-page-link active" href="setup.html"`) + + faqHTML := readAggregateFile(t, filepath.Join(outputDir, "faq.html")) + assert.Contains(t, faqHTML, "Hidden direct page.") + assert.NotContains(t, catalogNavSegment(faqHTML), `faq.html`) + + versionHTML := readAggregateFile(t, filepath.Join(outputDir, "services", "users", "versions", "v1", "index.html")) + assert.NotContains(t, versionHTML, `data-pp-preview-hold="true"`) + + users := findCatalogService(t, catalog, "users") + require.NotEmpty(t, users.LatestVersion.Entries) + entryDir := filepath.Dir(filepath.FromSlash(users.LatestVersion.Entries[0].OverviewHref)) + assert.NoFileExists(t, filepath.Join(outputDir, entryDir, "about.html")) + entryHTML := readAggregateFile(t, filepath.Join(outputDir, filepath.FromSlash(users.LatestVersion.Entries[0].OverviewHref))) + assert.NotContains(t, entryHTML, "Service-local content should not render") + + require.NoError(t, os.Remove(filepath.Join(root, "about.md"))) + apNext, err := CreateAggregatePrintingPressFromPath(root, &AggregatePrintingPressConfig{ + OutputDir: outputDir, + BuildMode: AggregateBuildModeFull, + StateStore: store, + Title: "Platform Catalog", + }) + require.NoError(t, err) + _, err = apNext.PrintHTML() + require.NoError(t, err) + assert.NoFileExists(t, filepath.Join(outputDir, "about.html")) + assert.NoFileExists(t, filepath.Join(outputDir, "assets", "docs", "about", "map.svg")) + assert.FileExists(t, filepath.Join(outputDir, "guides", "setup.html")) +} + func TestAggregatePrintingPress_PrintHTML_RendersCatalogDiagnosticsCounts(t *testing.T) { root := t.TempDir() usersRel := "services/users/src/specs/users.yaml" @@ -317,6 +434,23 @@ func TestCatalogRootContent_DisableSkippedRenderingSuppressesWarningBox(t *testi assert.Contains(t, rendered, `Users API`) } +func TestCatalogShell_WithContentNavIncludesVersionSelectScript(t *testing.T) { + page := catalogPageData{ + RelPath: pppaths.FileIndexHTML, + HeaderTitle: "Platform Catalog", + Title: "Platform Catalog", + Content: templ.ComponentFunc(func(ctx context.Context, w io.Writer) error { _, err := w.Write([]byte("content")); return err }), + ShowCatalogNav: true, + CatalogPages: []*ppmodel.ContentPage{ + {Title: "About", Label: "About", Slug: "about", Href: "about.html"}, + }, + ActiveNavSlug: "catalog", + } + var html strings.Builder + require.NoError(t, catalogShell(page).Render(context.Background(), &html)) + assert.Contains(t, html.String(), `sl-menu[data-catalog-version-menu]`) +} + func TestCatalogVersionPrimaryHref_UsesVersionOverviewForCollisions(t *testing.T) { version := &ppmodel.CatalogVersion{ OverviewHref: "services/account-service/versions/5/index.html", @@ -1185,6 +1319,25 @@ func readAggregateDiagnosticsPayload(t *testing.T, entryDir string) string { return string(payload) } +func readAggregateFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err) + return string(data) +} + +func catalogNavSegment(html string) string { + start := strings.Index(html, ``) + if end < 0 { + return html[start:] + } + return html[start : start+end+len(``)] +} + func writeAggregateSpec(t *testing.T, root, relPath, title, version string) string { return writeAggregateSpecWithDetails(t, root, relPath, title, "", "", version) } diff --git a/printingpress/aggregate_writer.go b/printingpress/aggregate_writer.go index d0dcc6c..10da742 100644 --- a/printingpress/aggregate_writer.go +++ b/printingpress/aggregate_writer.go @@ -95,6 +95,11 @@ func (ap *AggregatePrintingPress) buildEntrySite(spec *aggregateDiscoveredSpec, if err != nil { return nil, err } + // Aggregate-level Markdown belongs to the catalog shell only. Entry sites + // render the API reference without discovering sibling custom pages. + pp.config.contentDiscoveryEnabled = false + pp.config.contentBasePath = "" + pp.config.contentSpecPath = "" site, err := pp.PressModel() if err != nil { return nil, err @@ -451,12 +456,53 @@ func (ap *AggregatePrintingPress) writeCatalogHTML(catalog *ppmodel.CatalogSite) if err := ap.removeObsoleteCatalogHTML(catalog); err != nil { return nil, err } + contentStatePaths := make([]string, 0, len(catalog.ContentPages)+1) + for _, page := range catalog.ContentPages { + assetPaths, err := writeContentPageAssets(ap.config.OutputDir, page) + if err != nil { + return nil, err + } + written = append(written, assetPaths...) + for _, asset := range page.Assets { + if asset != nil { + contentStatePaths = append(contentStatePaths, asset.Href) + } + } + } rootPath := filepath.Join(ap.config.OutputDir, pppaths.FileIndexHTML) - if err := writeCatalogPage(rootPath, ap.catalogPageData(pppaths.FileIndexHTML, catalog.Title, catalog.Description, nil, false, catalogRootContent(catalog, ap.config.DisableSkippedRendering))); err != nil { + rootPage := ap.catalogPageData(pppaths.FileIndexHTML, catalog.Title, catalog.Description, nil, false, catalogRootContent(catalog, ap.config.DisableSkippedRendering)) + rootPage = withCatalogContentNav(rootPage, catalog, "catalog") + if err := writeCatalogPage(rootPath, rootPage); err != nil { return nil, err } written = append(written, rootPath) + if len(catalog.ContentPages) > 0 { + guidesPath := filepath.Join(ap.config.OutputDir, filepath.FromSlash(pppaths.GuidesIndexHTML())) + guidesPage := ap.catalogPageData(pppaths.GuidesIndexHTML(), "Guides", "", nil, false, render.ContentIndexTempl(catalog.ContentPages, render.GuidesIndexBreadcrumb(), "")) + guidesPage = withCatalogContentNav(guidesPage, catalog, "guides") + if err := writeCatalogPage(guidesPath, guidesPage); err != nil { + return nil, err + } + written = append(written, guidesPath) + contentStatePaths = append(contentStatePaths, pppaths.GuidesIndexHTML()) + } + + for _, contentPage := range catalog.ContentPages { + if contentPage == nil { + continue + } + contentPath := filepath.Join(ap.config.OutputDir, filepath.FromSlash(contentPage.Href)) + content := render.ContentPageTemplWithBreadcrumb(contentPage, "", catalogContentPageBreadcrumb(contentPage)) + page := ap.catalogPageData(contentPage.Href, fmt.Sprintf("%s - %s", contentPage.Title, catalog.Title), contentPage.Description, nil, false, content) + page = withCatalogContentNav(page, catalog, "content/"+contentPage.Slug) + if err := writeCatalogPage(contentPath, page); err != nil { + return nil, err + } + written = append(written, contentPath) + contentStatePaths = append(contentStatePaths, contentPage.Href) + } + for _, service := range catalog.Services { if !hasVisibleCatalogVersions(service) { continue @@ -472,6 +518,12 @@ func (ap *AggregatePrintingPress) writeCatalogHTML(catalog *ppmodel.CatalogSite) written = append(written, versionPath) } } + if err := ap.removePreviousCatalogContentArtifacts(contentStatePaths); err != nil { + return nil, err + } + if err := ap.writeCatalogContentState(contentStatePaths); err != nil { + return nil, err + } return written, nil } @@ -512,6 +564,115 @@ func (ap *AggregatePrintingPress) removeObsoleteCatalogHTML(catalog *ppmodel.Cat return nil } +type catalogContentState struct { + Paths []string `json:"paths"` +} + +func (ap *AggregatePrintingPress) removePreviousCatalogContentArtifacts(currentPaths []string) error { + paths, err := ap.readCatalogContentState() + if err != nil { + if ap != nil && ap.config != nil && ap.config.Logger != nil { + ap.config.Logger.Warn("printingpress: unable to read previous catalog content state", "error", err) + } + return nil + } + current := make(map[string]struct{}, len(currentPaths)) + for _, relPath := range currentPaths { + clean, ok := cleanCatalogContentStatePath(relPath) + if ok { + current[clean] = struct{}{} + } + } + for _, relPath := range paths { + clean, ok := cleanCatalogContentStatePath(relPath) + if !ok { + continue + } + if _, keep := current[clean]; keep { + continue + } + err := os.Remove(filepath.Join(ap.config.OutputDir, filepath.FromSlash(clean))) + if err != nil && !os.IsNotExist(err) { + return err + } + } + return nil +} + +func (ap *AggregatePrintingPress) readCatalogContentState() ([]string, error) { + statePath := filepath.Join(ap.config.OutputDir, pppaths.FileCatalogContentStateJSON) + data, err := os.ReadFile(statePath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var state catalogContentState + if err := json.Unmarshal(data, &state); err != nil { + return nil, err + } + return state.Paths, nil +} + +func (ap *AggregatePrintingPress) writeCatalogContentState(paths []string) error { + statePath := filepath.Join(ap.config.OutputDir, pppaths.FileCatalogContentStateJSON) + if len(paths) == 0 { + err := os.Remove(statePath) + if err != nil && !os.IsNotExist(err) { + return err + } + return nil + } + cleaned := make([]string, 0, len(paths)) + seen := make(map[string]struct{}, len(paths)) + for _, relPath := range paths { + clean, ok := cleanCatalogContentStatePath(relPath) + if !ok { + continue + } + if _, exists := seen[clean]; exists { + continue + } + seen[clean] = struct{}{} + cleaned = append(cleaned, clean) + } + if len(cleaned) == 0 { + return nil + } + sort.Strings(cleaned) + data, err := json.Marshal(catalogContentState{Paths: cleaned}) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(statePath), 0o755); err != nil { + return err + } + return os.WriteFile(statePath, data, 0o644) +} + +func cleanCatalogContentStatePath(relPath string) (string, bool) { + relPath = strings.TrimSpace(filepath.ToSlash(relPath)) + if relPath == "" || strings.Contains(relPath, "\x00") || strings.HasPrefix(relPath, "/") { + return "", false + } + clean := path.Clean(relPath) + if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") { + return "", false + } + if clean == pppaths.GuidesIndexHTML() || strings.HasPrefix(clean, path.Join(pppaths.DirAssets, "docs")+"/") { + return clean, true + } + if strings.ToLower(path.Ext(clean)) == pppaths.ExtHTML { + slugPath := strings.TrimSuffix(clean, pppaths.ExtHTML) + if isReservedContentSlug(slugPath) { + return "", false + } + return clean, true + } + return "", false +} + func (ap *AggregatePrintingPress) writeCatalogJSON(catalog *ppmodel.CatalogSite) ([]string, error) { var written []string rootBundle := struct { @@ -621,15 +782,18 @@ func (ap *AggregatePrintingPress) writeCatalogLLM(catalog *ppmodel.CatalogSite) } type catalogPageData struct { - RelPath string - HeaderTitle string - Title string - Subtitle string - Service *ppmodel.CatalogService - Content templ.Component - AssetBase string - ShowHeroTitle bool - Footer *ppmodel.FooterConfig + RelPath string + HeaderTitle string + Title string + Subtitle string + Service *ppmodel.CatalogService + Content templ.Component + AssetBase string + ShowHeroTitle bool + Footer *ppmodel.FooterConfig + ShowCatalogNav bool + CatalogPages []*ppmodel.ContentPage + ActiveNavSlug string } func (ap *AggregatePrintingPress) catalogPageData(relPath, title, subtitle string, service *ppmodel.CatalogService, showHeroTitle bool, content templ.Component) catalogPageData { @@ -650,6 +814,16 @@ func (ap *AggregatePrintingPress) catalogPageData(relPath, title, subtitle strin } } +func withCatalogContentNav(page catalogPageData, catalog *ppmodel.CatalogSite, activeSlug string) catalogPageData { + if catalog == nil { + return page + } + page.ShowCatalogNav = hasContentPagesForNav(catalog.ContentPages) + page.CatalogPages = catalog.ContentPages + page.ActiveNavSlug = activeSlug + return page +} + func (ap *AggregatePrintingPress) catalogAssetBase(relPath string) string { if ap.config.BaseURL != "" { return ap.config.BaseURL @@ -725,12 +899,16 @@ func catalogAssetHref(assetBase, href string) string { func catalogShell(page catalogPageData) templ.Component { return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error { + showNav := catalogPageHasNav(page) if _, err := io.WriteString(w, ``); err != nil { return err } if err := writeCatalogHead(w, page.Title, page.AssetBase); err != nil { return err } + if showNav { + return renderCatalogNavShell(ctx, w, page) + } if _, err := io.WriteString(w, `
`); err != nil { return err } @@ -742,7 +920,10 @@ func catalogShell(page catalogPageData) templ.Component { if _, err := io.WriteString(w, `
`); err != nil { return err } - if _, err := io.WriteString(w, `
`); err != nil { + if _, err := io.WriteString(w, ``); err != nil { + return err + } + if _, err := io.WriteString(w, `
`); err != nil { return err } if page.ShowHeroTitle || strings.TrimSpace(page.Subtitle) != "" { @@ -769,7 +950,65 @@ func catalogShell(page catalogPageData) templ.Component { if err := render.WriteFooter(w, page.Footer); err != nil { return err } - _, err := io.WriteString(w, `
`) - return err - }) + ` +} + +func writeCatalogOptionalAttr(w io.Writer, key, value string) error { + if strings.TrimSpace(value) == "" { + return nil + } + _, err := io.WriteString(w, ` `+key+`="`+templ.EscapeString(value)+`"`) + return err +} + +func catalogPageHasNav(page catalogPageData) bool { + return page.ShowCatalogNav && hasContentPagesForNav(page.CatalogPages) +} + +func catalogNavHTML(page catalogPageData) string { + if !catalogPageHasNav(page) { + return "" + } + fromDir := path.Dir(page.RelPath) + var builder strings.Builder + builder.WriteString(`
`) + builder.WriteString(catalogNavHomeHTML("API Catalog", relativeCatalogHref(fromDir, pppaths.FileIndexHTML), page.ActiveNavSlug == "catalog")) + builder.WriteString(`
`) + return builder.String() +} + +func catalogNavHomeHTML(label, href string, active bool) string { + classAttr := "nav-home" + if active { + classAttr += " active" + } + var builder strings.Builder + builder.WriteString(``) + builder.WriteString(templ.EscapeString(label)) + builder.WriteString(``) + return builder.String() +} + +func catalogNavPageLinkHTML(label, href string, active bool) string { + classAttr := "nav-page-link" + if active { + classAttr += " active" + } + var builder strings.Builder + builder.WriteString(``) + builder.WriteString(templ.EscapeString(label)) + builder.WriteString(``) + return builder.String() +} + +func catalogContentPageBreadcrumb(page *ppmodel.ContentPage) []render.BreadcrumbItem { + fromDir := "." + if page != nil { + fromDir = path.Dir(page.Href) + } + return []render.BreadcrumbItem{ + {Label: "HOME", Href: relativeCatalogHref(fromDir, pppaths.FileIndexHTML)}, + {Label: "GUIDES", Href: relativeCatalogHref(fromDir, pppaths.GuidesIndexHTML())}, + {Label: catalogContentPageBreadcrumbLabel(page)}, + } +} + +func catalogContentPageBreadcrumbLabel(page *ppmodel.ContentPage) string { + return strings.ToUpper(catalogContentPageNavLabel(page)) +} + +func catalogContentPageNavLabel(page *ppmodel.ContentPage) string { + if page == nil { + return "Untitled" + } + if strings.TrimSpace(page.Label) != "" { + return strings.TrimSpace(page.Label) + } + if strings.TrimSpace(page.Title) != "" { + return strings.TrimSpace(page.Title) + } + if strings.TrimSpace(page.Slug) != "" { + return strings.ReplaceAll(path.Base(strings.Trim(page.Slug, "/")), "-", " ") + } + return "Untitled" } func catalogRootContent(catalog *ppmodel.CatalogSite, disableSkippedRendering bool) templ.Component { diff --git a/printingpress/api.go b/printingpress/api.go index d4cb563..e1b4e5f 100644 --- a/printingpress/api.go +++ b/printingpress/api.go @@ -39,6 +39,9 @@ type PrintingPressConfig struct { LintResults []*v3.RuleFunctionResult Footer *ppmodel.FooterConfig Artifact *ArtifactManifestConfig + // EnableContentPages discovers conventional Markdown files such as about.md + // and docs/guide.md next to the local spec and renders them as guide pages. + EnableContentPages bool // MaxPatternRepeatBudget limits regex repeat work for generated mock strings. // A zero or negative value uses DefaultMaxPatternRepeatBudget. @@ -90,6 +93,10 @@ type PrintingPressConfig struct { // known URL (e.g. /ppress/static/{contentVersion}) and wants every artifact // to share that one copy instead of duplicating the bundle per generation. SharedAssetBaseURL string + + contentDiscoveryEnabled bool + contentBasePath string + contentSpecPath string } const ( @@ -371,6 +378,9 @@ func (pp *PrintingPress) prepareEngineConfig(job *activityJob) (*pressEngineConf SharedAssetBaseURL: pp.config.SharedAssetBaseURL, Title: pp.config.Title, SpecURL: pp.config.SpecURL, + ContentDiscoveryEnabled: pp.config.contentDiscoveryEnabled, + ContentBasePath: pp.config.contentBasePath, + ContentSpecPath: pp.config.contentSpecPath, Logger: slog.Default(), DeveloperMode: pp.config.DeveloperMode, DocsExpiresAt: printingPressExpiryString(pp.config.ExpiresAt), @@ -663,25 +673,34 @@ func validateAndNormalizeConfig(config *PrintingPressConfig, source pressSource) } } + explicitSpecPath := strings.TrimSpace(config.SpecPath) != "" if normalized.BasePath != "" { - abs, err := filepath.Abs(normalized.BasePath) - if err != nil { + if isURLString(normalized.BasePath) { issues = append(issues, ValidationIssue{ Field: "basePath", Err: ErrInvalidBasePath, - Message: fmt.Sprintf("%s: %v", ErrInvalidBasePath.Error(), err), - }) - } else if info, err := os.Stat(abs); err != nil || !info.IsDir() { - if err == nil { - err = fmt.Errorf("not a directory") - } - issues = append(issues, ValidationIssue{ - Field: "basePath", - Err: ErrInvalidBasePath, - Message: fmt.Sprintf("%s: %v", ErrInvalidBasePath.Error(), err), + Message: fmt.Sprintf("%s: must be a local directory", ErrInvalidBasePath.Error()), }) } else { - normalized.BasePath = abs + abs, err := filepath.Abs(normalized.BasePath) + if err != nil { + issues = append(issues, ValidationIssue{ + Field: "basePath", + Err: ErrInvalidBasePath, + Message: fmt.Sprintf("%s: %v", ErrInvalidBasePath.Error(), err), + }) + } else if info, err := os.Stat(abs); err != nil || !info.IsDir() { + if err == nil { + err = fmt.Errorf("not a directory") + } + issues = append(issues, ValidationIssue{ + Field: "basePath", + Err: ErrInvalidBasePath, + Message: fmt.Sprintf("%s: %v", ErrInvalidBasePath.Error(), err), + }) + } else { + normalized.BasePath = abs + } } } else if len(source.specBytes) > 0 { wd, err := os.Getwd() @@ -698,17 +717,23 @@ func validateAndNormalizeConfig(config *PrintingPressConfig, source pressSource) if len(source.specBytes) > 0 { if normalized.SpecPath != "" { - abs, err := filepath.Abs(normalized.SpecPath) - if err != nil { - issues = append(issues, ValidationIssue{ - Field: "specPath", - Err: ErrInvalidBasePath, - Message: fmt.Sprintf("%s: %v", ErrInvalidBasePath.Error(), err), - }) + if isURLString(normalized.SpecPath) { + // Remote source metadata is allowed, but libopenapi's BasePath is a + // local filesystem root. Leave BasePath unset so resolution uses the + // current working directory instead of treating a URL as a path. } else { - normalized.SpecPath = abs - if normalized.BasePath == "" { - normalized.BasePath = filepath.Dir(abs) + abs, err := filepath.Abs(normalized.SpecPath) + if err != nil { + issues = append(issues, ValidationIssue{ + Field: "specPath", + Err: ErrInvalidBasePath, + Message: fmt.Sprintf("%s: %v", ErrInvalidBasePath.Error(), err), + }) + } else { + normalized.SpecPath = abs + if normalized.BasePath == "" { + normalized.BasePath = filepath.Dir(abs) + } } } } else { @@ -719,7 +744,9 @@ func validateAndNormalizeConfig(config *PrintingPressConfig, source pressSource) base = wd } } - if base != "" { + if isURLString(base) { + normalized.SpecPath = strings.TrimRight(base, "/") + "/" + filename + } else if base != "" { normalized.SpecPath = filepath.Join(base, filename) } else { normalized.SpecPath = filename @@ -727,6 +754,15 @@ func validateAndNormalizeConfig(config *PrintingPressConfig, source pressSource) } } + normalized.contentDiscoveryEnabled = normalized.EnableContentPages && normalized.BasePath != "" && !isURLString(normalized.BasePath) + if normalized.contentDiscoveryEnabled { + normalized.contentBasePath = normalized.BasePath + normalized.contentSpecPath = "" + if explicitSpecPath { + normalized.contentSpecPath = normalized.SpecPath + } + } + if source.drModel != nil && source.drModel.V3Document == nil { issues = append(issues, ValidationIssue{ Field: "drModel", diff --git a/printingpress/api_test.go b/printingpress/api_test.go index 666dd17..cae56c0 100644 --- a/printingpress/api_test.go +++ b/printingpress/api_test.go @@ -487,6 +487,19 @@ func TestCreatePrintingPress_ValidationRejectsInvalidAssetMode(t *testing.T) { assert.Contains(t, validationErr.Error(), "asset mode") } +func TestCreatePrintingPress_ValidationRejectsURLBasePath(t *testing.T) { + _, err := CreatePrintingPressFromBytes([]byte("openapi: 3.1.0\ninfo:\n title: x\n version: 1\npaths: {}\n"), &PrintingPressConfig{ + BasePath: "https://example.com/specs", + }) + require.Error(t, err) + + var validationErr *ValidationError + require.ErrorAs(t, err, &validationErr) + assert.ErrorIs(t, err, ErrInvalidBasePath) + assert.Contains(t, validationErr.Error(), "basePath") + assert.Contains(t, validationErr.Error(), "must be a local directory") +} + func TestCreatePrintingPress_ValidationRejectsInvalidLLMMonolithMode(t *testing.T) { _, err := CreatePrintingPressFromBytes([]byte("openapi: 3.1.0\ninfo:\n title: x\n version: 1\npaths: {}\n"), &PrintingPressConfig{ LLMGenerateMonoliths: "sometimes", diff --git a/printingpress/cmd/press/main.go b/printingpress/cmd/press/main.go index 79c9beb..425cce1 100644 --- a/printingpress/cmd/press/main.go +++ b/printingpress/cmd/press/main.go @@ -51,10 +51,11 @@ func main() { } pp, err := printingpress.CreatePrintingPressFromBytes(specBytes, &printingpress.PrintingPressConfig{ - Title: *title, - BaseURL: *baseURL, - BasePath: base, - OutputDir: resolvedOutput, + Title: *title, + BaseURL: *baseURL, + BasePath: base, + OutputDir: resolvedOutput, + EnableContentPages: true, }) if err != nil { logger.Error("create printing press failed", "error", err) diff --git a/printingpress/content_loader.go b/printingpress/content_loader.go new file mode 100644 index 0000000..168ba00 --- /dev/null +++ b/printingpress/content_loader.go @@ -0,0 +1,291 @@ +// Copyright 2024-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// https://pb33f.io +// SPDX-License-Identifier: Apache-2.0 + +package printingpress + +import ( + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "strings" + "time" +) + +var ( + errContentNotFound = errors.New("content not found") + errContentOutsideRoot = errors.New("content path resolves outside the allowed root") + errContentSizeLimit = errors.New("content exceeds size limit") + errContentUnsupportedURL = errors.New("unsupported content URL") +) + +type pageLoadResult struct { + Path string + Size int64 + Status int + NotFound bool +} + +type pageLoader interface { + Read(rawPath string, limit int64) ([]byte, pageLoadResult, error) + Exists(rawPath string) (bool, error) + Resolve(base, ref string) (string, error) +} + +type contentLoader struct { + allowedRoots []string + client *http.Client +} + +func newContentLoader(allowedRoots ...string) *contentLoader { + roots := make([]string, 0, len(allowedRoots)) + for _, root := range allowedRoots { + trimmed := strings.TrimSpace(root) + if trimmed == "" || isURLString(trimmed) { + continue + } + abs, err := filepath.Abs(trimmed) + if err != nil { + continue + } + evaluated, err := filepath.EvalSymlinks(abs) + if err == nil { + abs = evaluated + } + roots = append(roots, filepath.Clean(abs)) + } + return &contentLoader{ + allowedRoots: roots, + client: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +func (l *contentLoader) Exists(rawPath string) (bool, error) { + if isURLString(rawPath) { + _, result, err := l.Read(rawPath, contentPageReadLimit) + if err == nil { + return true, nil + } + if result.NotFound || errors.Is(err, errContentNotFound) { + return false, nil + } + return false, err + } + resolved, err := l.resolveLocalPath(rawPath) + if err != nil { + if errors.Is(err, errContentNotFound) { + return false, nil + } + return false, err + } + info, err := os.Stat(resolved) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + return !info.IsDir(), nil +} + +func (l *contentLoader) Read(rawPath string, limit int64) ([]byte, pageLoadResult, error) { + if limit <= 0 { + limit = 1 << 20 + } + if isURLString(rawPath) { + return l.readURL(rawPath, limit) + } + return l.readLocal(rawPath, limit) +} + +func (l *contentLoader) Resolve(base, ref string) (string, error) { + ref = strings.TrimSpace(ref) + if ref == "" { + return "", fmt.Errorf("empty content reference") + } + if isURLString(ref) { + return ref, nil + } + if isURLString(base) { + return resolveURLReference(ensureURLDirectory(base), ref) + } + if filepath.IsAbs(ref) { + return filepath.Clean(ref), nil + } + return filepath.Clean(filepath.Join(base, filepath.FromSlash(ref))), nil +} + +func (l *contentLoader) readLocal(rawPath string, limit int64) ([]byte, pageLoadResult, error) { + resolved, err := l.resolveLocalPath(rawPath) + result := pageLoadResult{Path: resolved} + if err != nil { + if errors.Is(err, errContentNotFound) { + result.NotFound = true + } + return nil, result, err + } + info, err := os.Stat(resolved) + if err != nil { + if os.IsNotExist(err) { + result.NotFound = true + return nil, result, errContentNotFound + } + return nil, result, err + } + if info.IsDir() { + result.NotFound = true + return nil, result, errContentNotFound + } + if info.Size() > limit { + result.Size = info.Size() + return nil, result, errContentSizeLimit + } + file, err := os.Open(resolved) + if err != nil { + return nil, result, err + } + defer file.Close() + data, err := readLimited(file, limit) + result.Size = int64(len(data)) + return data, result, err +} + +func (l *contentLoader) resolveLocalPath(rawPath string) (string, error) { + if strings.TrimSpace(rawPath) == "" { + return "", errContentNotFound + } + abs, err := filepath.Abs(rawPath) + if err != nil { + return "", err + } + if _, err := os.Lstat(abs); err != nil { + if os.IsNotExist(err) { + return filepath.Clean(abs), errContentNotFound + } + return "", err + } + evaluated, err := filepath.EvalSymlinks(abs) + if err != nil { + if os.IsNotExist(err) { + return filepath.Clean(abs), errContentNotFound + } + return "", err + } + evaluated = filepath.Clean(evaluated) + if len(l.allowedRoots) == 0 { + return evaluated, nil + } + for _, root := range l.allowedRoots { + if isWithinRoot(evaluated, root) { + return evaluated, nil + } + } + return evaluated, errContentOutsideRoot +} + +func (l *contentLoader) readURL(rawURL string, limit int64) ([]byte, pageLoadResult, error) { + result := pageLoadResult{Path: rawURL} + parsed, err := url.Parse(rawURL) + if err != nil { + return nil, result, err + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return nil, result, errContentUnsupportedURL + } + req, err := http.NewRequest(http.MethodGet, rawURL, nil) + if err != nil { + return nil, result, err + } + resp, err := l.client.Do(req) + if err != nil { + return nil, result, err + } + defer resp.Body.Close() + result.Status = resp.StatusCode + if resp.StatusCode == http.StatusNotFound { + result.NotFound = true + return nil, result, errContentNotFound + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return nil, result, fmt.Errorf("unexpected HTTP status %d", resp.StatusCode) + } + if resp.ContentLength > limit { + result.Size = resp.ContentLength + return nil, result, errContentSizeLimit + } + data, err := readLimited(resp.Body, limit) + result.Size = int64(len(data)) + return data, result, err +} + +func readLimited(reader io.Reader, limit int64) ([]byte, error) { + limited := &io.LimitedReader{R: reader, N: limit + 1} + data, err := io.ReadAll(limited) + if err != nil { + return nil, err + } + if int64(len(data)) > limit { + return nil, errContentSizeLimit + } + return data, nil +} + +func isWithinRoot(file, root string) bool { + rel, err := filepath.Rel(root, file) + if err != nil { + return false + } + return rel == "." || (!strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != "..") +} + +func isURLString(value string) bool { + parsed, err := url.Parse(strings.TrimSpace(value)) + return err == nil && parsed.Scheme != "" && parsed.Host != "" +} + +func resolveURLReference(base, ref string) (string, error) { + parsedBase, err := url.Parse(base) + if err != nil { + return "", err + } + parsedRef, err := url.Parse(ref) + if err != nil { + return "", err + } + if parsedRef.IsAbs() { + return parsedRef.String(), nil + } + parsedBase = ensureParsedURLDirectory(parsedBase) + resolved := parsedBase.ResolveReference(parsedRef) + resolved.Path = path.Clean(resolved.Path) + return resolved.String(), nil +} + +func ensureURLDirectory(raw string) string { + parsed, err := url.Parse(raw) + if err != nil { + return raw + } + return ensureParsedURLDirectory(parsed).String() +} + +func ensureParsedURLDirectory(parsed *url.URL) *url.URL { + if parsed == nil { + return parsed + } + copy := *parsed + if copy.Path == "" { + copy.Path = "/" + } + if !strings.HasSuffix(copy.Path, "/") { + copy.Path += "/" + } + return © +} diff --git a/printingpress/content_pages.go b/printingpress/content_pages.go new file mode 100644 index 0000000..b2706e3 --- /dev/null +++ b/printingpress/content_pages.go @@ -0,0 +1,816 @@ +// Copyright 2024-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// https://pb33f.io +// SPDX-License-Identifier: Apache-2.0 + +package printingpress + +import ( + "bytes" + "errors" + "fmt" + "net/url" + "path" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "unicode" + + "github.com/pb33f/doctor/printingpress/internal/pppaths" + ppmodel "github.com/pb33f/doctor/printingpress/model" + slugpkg "github.com/pb33f/doctor/printingpress/slug" + "gopkg.in/yaml.v3" +) + +const ( + contentPageReadLimit int64 = 2 * 1024 * 1024 + contentPartialReadLimit int64 = 512 * 1024 + contentPartialExpandLimit int = 2 * 1024 * 1024 + contentPartialMaxDepth = 8 + contentPartialMaxExpansions = 128 + contentImageAssetReadLimit int64 = 5 * 1024 * 1024 +) + +var partialPattern = regexp.MustCompile(`\{\{<\s*partial\s+"([^"]+)"\s*>\}\}`) + +type conventionalContentPage struct { + key string + filename string + title string + slug string + order int + specialGuide bool +} + +var conventionalContentPages = []conventionalContentPage{ + {key: "about", filename: "about.md", title: "About", slug: "about", order: 10}, + {key: "quickstart", filename: "quickstart.md", title: "Quickstart", slug: "quickstart", order: 20}, + {key: "guide", filename: "guide.md", title: "Guide", slug: "guide", order: 30}, + {key: "auth", filename: "auth.md", title: "Auth", slug: "auth", order: 40}, + {key: "security", filename: "security.md", title: "Security", slug: "security", order: 50}, + {key: "errors", filename: "errors.md", title: "Errors", slug: "errors", order: 60}, + {key: "webhooks", filename: "webhooks.md", title: "Webhooks Guide", slug: "webhooks-guide", order: 70, specialGuide: true}, + {key: "sdks", filename: "sdks.md", title: "SDKs", slug: "sdks", order: 80}, + {key: "faq", filename: "faq.md", title: "FAQ", slug: "faq", order: 90}, + {key: "changelog", filename: "changelog.md", title: "Changelog", slug: "changelog", order: 100}, +} + +var reservedContentRoutes = map[string]struct{}{ + strings.TrimSuffix(pppaths.FileIndexHTML, pppaths.ExtHTML): {}, + strings.TrimSuffix(pppaths.FileBundleJSON, pppaths.ExtJSON): {}, + strings.TrimSuffix(pppaths.FileManifestJSON, pppaths.ExtJSON): {}, + strings.TrimSuffix(pppaths.FileNavJSON, pppaths.ExtJSON): {}, + pppaths.DiagnosticsSlug: {}, + pppaths.DirOperations: {}, + pppaths.DirModels: {}, + pppaths.DirTags: {}, + pppaths.DirContent: {}, + pppaths.DirServices: {}, + pppaths.DirVersions: {}, + pppaths.DirSpecs: {}, + pppaths.DirStatic: {}, + pppaths.DirAssets: {}, + pppaths.DirData: {}, + "webhooks": {}, +} + +var reservedContentExactRoutes = map[string]struct{}{ + strings.TrimSuffix(pppaths.FileGuidesHTML, pppaths.ExtHTML): {}, +} + +var supportedContentImageExtensions = map[string]struct{}{ + ".png": {}, + ".jpg": {}, + ".jpeg": {}, + ".gif": {}, + ".webp": {}, + ".svg": {}, +} + +type contentPageDraft struct { + convention conventionalContentPage + sourcePath string + sourceDir string + aliases []string + body string + meta contentPageFrontMatter + hasMeta bool + fromDocs bool +} + +type contentPageFrontMatter struct { + Title string `yaml:"title"` + Label string `yaml:"label"` + Slug string `yaml:"slug"` + Order *int `yaml:"order"` + Description string `yaml:"description"` + Hidden bool `yaml:"hidden"` +} + +type contentPageContext struct { + root string + docsRoot string + loader *contentLoader + linkByPath map[string]string +} + +func (pp *PrintingPress) collectContentPages() { + ctx := pp.newContentPageContext() + if ctx == nil { + return + } + drafts := pp.discoverContentPageDrafts(ctx) + if len(drafts) == 0 { + return + } + pages := pp.resolveContentPages(drafts) + if len(pages) == 0 { + return + } + ctx.linkByPath = make(map[string]string, len(pages)) + for _, page := range pages { + if page == nil { + continue + } + ctx.linkByPath[canonicalContentPath(ctx.loader, page.SourcePath)] = page.Href + for _, alias := range page.SourceAlias { + ctx.linkByPath[canonicalContentPath(ctx.loader, alias)] = page.Href + } + } + for _, page := range pages { + pp.renderContentPage(ctx, page) + } + pp.site.ContentPages = pages +} + +func (pp *PrintingPress) newContentPageContext() *contentPageContext { + if pp == nil || pp.engineConfig == nil || !pp.engineConfig.ContentDiscoveryEnabled { + return nil + } + root := strings.TrimSpace(pp.engineConfig.ContentBasePath) + if root == "" { + return nil + } + if pp.engineConfig.ContentSpecPath != "" { + if isURLString(pp.engineConfig.ContentSpecPath) { + if isURLString(root) { + root = strings.TrimRight(root, "/") + } + } else { + root = filepath.Dir(pp.engineConfig.ContentSpecPath) + } + } + docsRoot := "" + if isURLString(root) { + if resolved, err := resolveURLReference(ensureURLDirectory(root), "docs"); err == nil { + docsRoot = resolved + } + } else { + docsRoot = filepath.Join(root, "docs") + } + loader := newContentLoader(root, docsRoot) + if resolved, err := loader.Resolve(root, "docs"); err == nil { + docsRoot = resolved + } else { + docsRoot = "" + } + return &contentPageContext{ + root: root, + docsRoot: docsRoot, + loader: loader, + } +} + +func (pp *PrintingPress) discoverContentPageDrafts(ctx *contentPageContext) []*contentPageDraft { + drafts := make([]*contentPageDraft, 0, len(conventionalContentPages)) + for _, convention := range conventionalContentPages { + rootPath, err := ctx.loader.Resolve(ctx.root, convention.filename) + if err != nil { + pp.warnContentPage("custom page path could not be resolved; skipping", convention.filename, err) + continue + } + docsPath := "" + if ctx.docsRoot != "" { + docsPath, _ = ctx.loader.Resolve(ctx.docsRoot, convention.filename) + } + rootData, rootResult, rootErr := ctx.loader.Read(rootPath, contentPageReadLimit) + if rootErr == nil { + var aliases []string + if docsPath != "" { + if exists, err := ctx.loader.Exists(docsPath); err == nil && exists { + pp.warnContentPage("custom page in docs ignored because root page takes precedence", docsPath, nil) + aliases = append(aliases, docsPath) + } + } + draft := pp.newContentPageDraft(ctx, convention, rootResult.Path, rootData, false) + if draft != nil { + draft.aliases = aliases + drafts = append(drafts, draft) + } + continue + } + if !isContentNotFound(rootErr, rootResult) { + pp.warnContentPage("custom page could not be read; skipping", rootPath, rootErr) + continue + } + if docsPath == "" { + continue + } + docsData, docsResult, docsErr := ctx.loader.Read(docsPath, contentPageReadLimit) + if docsErr == nil { + draft := pp.newContentPageDraft(ctx, convention, docsResult.Path, docsData, true) + if draft != nil { + drafts = append(drafts, draft) + } + continue + } + if !isContentNotFound(docsErr, docsResult) { + pp.warnContentPage("custom page could not be read; skipping", docsPath, docsErr) + } + } + return drafts +} + +func (pp *PrintingPress) newContentPageDraft(ctx *contentPageContext, convention conventionalContentPage, sourcePath string, data []byte, fromDocs bool) *contentPageDraft { + body, meta, hasMeta, err := parseContentFrontMatter(string(data)) + if !hasMeta { + pp.warnContentPage("custom page has no front matter; using filename defaults", sourcePath, nil) + } + if err != nil { + pp.warnContentPage("custom page front matter could not be parsed; using filename defaults", sourcePath, err) + meta = contentPageFrontMatter{} + } + sourceDir := contentDir(sourcePath) + return &contentPageDraft{ + convention: convention, + sourcePath: sourcePath, + sourceDir: sourceDir, + body: body, + meta: meta, + hasMeta: hasMeta, + fromDocs: fromDocs, + } +} + +func (pp *PrintingPress) resolveContentPages(drafts []*contentPageDraft) []*ppmodel.ContentPage { + sort.SliceStable(drafts, func(i, j int) bool { + left, right := drafts[i], drafts[j] + leftOrder := contentDraftOrder(left) + rightOrder := contentDraftOrder(right) + if leftOrder != rightOrder { + return leftOrder < rightOrder + } + if left.convention.order != right.convention.order { + return left.convention.order < right.convention.order + } + return left.sourcePath < right.sourcePath + }) + pages := make([]*ppmodel.ContentPage, 0, len(drafts)) + used := make(map[string]struct{}, len(drafts)) + for _, draft := range drafts { + page := pp.resolveContentPage(draft) + if page == nil { + continue + } + if _, exists := used[page.Slug]; exists { + pp.warnContentPage("custom page slug conflicts with an earlier page; dropping page", page.Slug, nil) + continue + } + used[page.Slug] = struct{}{} + pages = append(pages, page) + } + return pages +} + +func (pp *PrintingPress) resolveContentPage(draft *contentPageDraft) *ppmodel.ContentPage { + if draft == nil { + return nil + } + defaultTitle := draft.convention.title + defaultSlug := draft.convention.slug + title := strings.TrimSpace(draft.meta.Title) + if title == "" { + title = defaultTitle + } + label := strings.TrimSpace(draft.meta.Label) + if label == "" { + label = title + } + rawSlug := strings.TrimSpace(draft.meta.Slug) + if rawSlug == "" { + rawSlug = defaultSlug + } + slugPath, ok := sanitizeContentSlugPath(rawSlug) + if !ok || isReservedContentSlug(slugPath) { + pp.warnContentPage("custom page slug is reserved or invalid; using filename default", rawSlug, nil) + slugPath, ok = sanitizeContentSlugPath(defaultSlug) + if !ok || isReservedContentSlug(slugPath) { + pp.warnContentPage("custom page default slug is reserved or invalid; dropping page", draft.sourcePath, nil) + return nil + } + } + return &ppmodel.ContentPage{ + Title: title, + Label: label, + Slug: slugPath, + Href: pppaths.ContentPageHTML(slugPath), + Description: strings.TrimSpace(draft.meta.Description), + SourcePath: draft.sourcePath, + SourceDir: draft.sourceDir, + SourceAlias: append([]string(nil), draft.aliases...), + Body: draft.body, + Order: contentDraftOrder(draft), + Hidden: draft.meta.Hidden, + } +} + +func (pp *PrintingPress) renderContentPage(ctx *contentPageContext, page *ppmodel.ContentPage) { + if ctx == nil || page == nil { + return + } + body := pp.expandContentPartials(ctx, page, page.Body) + extension := &contentRewriteExtension{ + rewriteLink: func(raw string) (string, bool) { + return pp.rewriteContentPageLink(ctx, page, raw) + }, + rewriteImage: func(raw string) (string, bool) { + return pp.rewriteContentPageImage(ctx, page, raw) + }, + } + page.BodyHTML = renderMarkdownWithExtensions(body, pp.engineConfig.NoMermaid, extension) +} + +type contentPartialExpansionState struct { + expansions int + truncated bool +} + +func (pp *PrintingPress) expandContentPartials(ctx *contentPageContext, page *ppmodel.ContentPage, body string) string { + state := &contentPartialExpansionState{} + expanded := pp.expandContentPartialsWithState(ctx, page, body, nil, 0, state) + if state.truncated { + pp.warnContentPage("custom page partial expansion exceeded max size", page.SourcePath, nil) + } + return expanded +} + +func (pp *PrintingPress) expandContentPartialsWithState(ctx *contentPageContext, page *ppmodel.ContentPage, body string, stack map[string]struct{}, depth int, state *contentPartialExpansionState) string { + if state == nil { + state = &contentPartialExpansionState{} + } + if depth > contentPartialMaxDepth { + pp.warnContentPage("custom page partial expansion exceeded max depth", page.SourcePath, nil) + return body + } + if len(body) > contentPartialExpandLimit { + pp.warnContentPage("custom page partial expansion exceeded max size", page.SourcePath, nil) + return body[:contentPartialExpandLimit] + } + var builder strings.Builder + inFence := false + for len(body) > 0 && !state.truncated { + line := body + body = "" + if idx := strings.IndexByte(line, '\n'); idx >= 0 { + line, body = line[:idx+1], line[idx+1:] + } + if isContentFenceLine(line) { + appendContentPartialLimited(&builder, line, state) + inFence = !inFence + continue + } + if inFence { + appendContentPartialLimited(&builder, line, state) + continue + } + pp.expandContentPartialSegment(ctx, page, line, stack, depth, state, &builder) + } + return builder.String() +} + +func (pp *PrintingPress) expandContentPartialSegment(ctx *contentPageContext, page *ppmodel.ContentPage, segment string, stack map[string]struct{}, depth int, state *contentPartialExpansionState, builder *strings.Builder) { + for len(segment) > 0 && !state.truncated { + match := partialPattern.FindStringSubmatchIndex(segment) + if match == nil { + appendContentPartialLimited(builder, segment, state) + return + } + appendContentPartialLimited(builder, segment[:match[0]], state) + if state.truncated { + return + } + name := strings.TrimSpace(segment[match[2]:match[3]]) + segment = segment[match[1]:] + if name == "" || strings.Contains(name, "\x00") { + pp.warnContentPage("custom page partial path is invalid", page.SourcePath, nil) + appendContentPartialLimited(builder, visibleContentPlaceholder("partial could not be loaded"), state) + continue + } + if state.expansions >= contentPartialMaxExpansions { + pp.warnContentPage("custom page partial expansion exceeded max include count", page.SourcePath, nil) + appendContentPartialLimited(builder, visibleContentPlaceholder("partial expansion limit reached"), state) + continue + } + state.expansions++ + data, source, err := pp.readContentPartial(ctx, name) + if err != nil { + pp.warnContentPage("custom page partial could not be loaded", name, err) + appendContentPartialLimited(builder, visibleContentPlaceholder("partial "+strconv.Quote(name)+" could not be loaded"), state) + continue + } + key := canonicalContentPath(ctx.loader, source) + if stack == nil { + stack = make(map[string]struct{}) + } + if _, seen := stack[key]; seen { + pp.warnContentPage("custom page partial include cycle detected", name, nil) + appendContentPartialLimited(builder, visibleContentPlaceholder("partial "+strconv.Quote(name)+" could not be loaded"), state) + continue + } + nextStack := make(map[string]struct{}, len(stack)+1) + for k, v := range stack { + nextStack[k] = v + } + nextStack[key] = struct{}{} + partialBody, _, _, _ := parseContentFrontMatter(string(data)) + expanded := pp.expandContentPartialsWithState(ctx, page, partialBody, nextStack, depth+1, state) + appendContentPartialLimited(builder, expanded, state) + } +} + +func appendContentPartialLimited(builder *strings.Builder, value string, state *contentPartialExpansionState) { + if value == "" || state.truncated { + return + } + remaining := contentPartialExpandLimit - builder.Len() + if remaining <= 0 { + state.truncated = true + return + } + if len(value) > remaining { + builder.WriteString(value[:remaining]) + state.truncated = true + return + } + builder.WriteString(value) +} + +func isContentFenceLine(line string) bool { + trimmed := strings.TrimLeft(line, " \t") + return strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") +} + +func (pp *PrintingPress) readContentPartial(ctx *contentPageContext, name string) ([]byte, string, error) { + sourcePartialRoot, _ := ctx.loader.Resolve(ctx.root, "_partials") + docsPartialRoot := "" + if ctx.docsRoot != "" { + docsPartialRoot, _ = ctx.loader.Resolve(ctx.docsRoot, "_partials") + } + rootPath, err := ctx.loader.Resolve(sourcePartialRoot, name) + if err != nil { + return nil, "", err + } + rootData, rootResult, rootErr := ctx.loader.Read(rootPath, contentPartialReadLimit) + if rootErr == nil { + if docsPartialRoot != "" { + if docsPath, err := ctx.loader.Resolve(docsPartialRoot, name); err == nil { + if exists, err := ctx.loader.Exists(docsPath); err == nil && exists { + pp.warnContentPage("custom page partial in docs ignored because root partial takes precedence", docsPath, nil) + } + } + } + return rootData, rootResult.Path, nil + } + if !isContentNotFound(rootErr, rootResult) { + return nil, rootPath, rootErr + } + if docsPartialRoot == "" { + return nil, rootPath, errContentNotFound + } + docsPath, err := ctx.loader.Resolve(docsPartialRoot, name) + if err != nil { + return nil, "", err + } + docsData, docsResult, docsErr := ctx.loader.Read(docsPath, contentPartialReadLimit) + if docsErr != nil { + return nil, docsPath, docsErr + } + return docsData, docsResult.Path, nil +} + +func (pp *PrintingPress) rewriteContentPageLink(ctx *contentPageContext, page *ppmodel.ContentPage, raw string) (string, bool) { + if shouldSkipContentHref(raw) { + return "", false + } + target, fragment := splitMarkdownHref(raw) + if strings.TrimSpace(target) == "" { + return "", false + } + if strings.ToLower(path.Ext(target)) != pppaths.ExtMarkdown { + return "", false + } + resolved, err := ctx.loader.Resolve(page.SourceDir, target) + if err != nil { + pp.warnContentPage("custom page link could not be resolved", raw, err) + return "", false + } + href, ok := ctx.linkByPath[canonicalContentPath(ctx.loader, resolved)] + if !ok { + pp.warnContentPage("custom page link target was not rendered; leaving original link", raw, nil) + return "", false + } + return relativeContentHref(page.Href, href) + fragment, true +} + +func (pp *PrintingPress) rewriteContentPageImage(ctx *contentPageContext, page *ppmodel.ContentPage, raw string) (string, bool) { + if shouldSkipContentHref(raw) { + return "", false + } + target, fragment := splitMarkdownHref(raw) + ext := strings.ToLower(path.Ext(target)) + if _, ok := supportedContentImageExtensions[ext]; !ok { + return "", false + } + resolved, err := ctx.loader.Resolve(page.SourceDir, target) + if err != nil { + pp.warnContentPage("custom page image could not be resolved", raw, err) + return "", false + } + data, result, err := ctx.loader.Read(resolved, contentImageAssetReadLimit) + if err != nil { + if errors.Is(err, errContentSizeLimit) { + pp.warnContentPage("custom page image exceeds 5 MB; leaving original link", raw, err) + } else { + pp.warnContentPage("custom page image could not be copied; leaving original link", raw, err) + } + _ = result + return "", false + } + name := safeContentAssetName(target) + sourceKey := canonicalContentPath(ctx.loader, result.Path) + href := pppaths.ContentAsset(page.Slug, name) + for attempt := 0; ; attempt++ { + conflict := false + for _, existing := range page.Assets { + if existing == nil || existing.Href != href { + continue + } + if existing.SourcePath == sourceKey || bytes.Equal(existing.Data, data) { + return relativeContentHref(page.Href, href) + fragment, true + } + conflict = true + break + } + if !conflict { + break + } + href = pppaths.ContentAsset(page.Slug, disambiguateContentAssetName(name, attempt+2)) + } + page.Assets = append(page.Assets, &ppmodel.ContentPageAsset{Href: href, SourcePath: sourceKey, Data: data}) + return relativeContentHref(page.Href, href) + fragment, true +} + +func parseContentFrontMatter(raw string) (body string, meta contentPageFrontMatter, hasMeta bool, err error) { + normalized := strings.ReplaceAll(raw, "\r\n", "\n") + if !strings.HasPrefix(normalized, "---\n") { + return raw, meta, false, nil + } + rest := normalized[len("---\n"):] + end, delimiterLen := findContentFrontMatterClose(rest) + if end < 0 { + return raw, meta, false, fmt.Errorf("front matter close delimiter not found") + } + fm := rest[:end] + bodyStart := end + delimiterLen + body = rest[bodyStart:] + if err := yaml.Unmarshal([]byte(fm), &meta); err != nil { + return raw, contentPageFrontMatter{}, true, err + } + if strings.TrimSpace(fm) != "" && !contentFrontMatterHasRecognizedKey(meta) { + return raw, contentPageFrontMatter{}, true, fmt.Errorf("front matter did not contain recognized keys") + } + return body, meta, true, nil +} + +func contentFrontMatterHasRecognizedKey(meta contentPageFrontMatter) bool { + return strings.TrimSpace(meta.Title) != "" || + strings.TrimSpace(meta.Label) != "" || + strings.TrimSpace(meta.Slug) != "" || + meta.Order != nil || + strings.TrimSpace(meta.Description) != "" || + meta.Hidden +} + +func findContentFrontMatterClose(rest string) (int, int) { + offset := 0 + for len(rest) > 0 { + line := rest + advance := len(rest) + if idx := strings.IndexByte(rest, '\n'); idx >= 0 { + line = rest[:idx] + advance = idx + 1 + } + trimmed := strings.TrimSpace(line) + if trimmed == "---" { + return offset, advance + } + offset += advance + rest = rest[advance:] + } + return -1, 0 +} + +func contentDraftOrder(draft *contentPageDraft) int { + if draft != nil && draft.meta.Order != nil { + return *draft.meta.Order + } + if draft == nil { + return 0 + } + return draft.convention.order +} + +func sanitizeContentSlugPath(raw string) (string, bool) { + raw = strings.TrimSpace(strings.ReplaceAll(raw, "\\", "/")) + raw = strings.Trim(raw, "/") + if raw == "" || raw == "." { + return "", false + } + segments := strings.Split(raw, "/") + sanitized := make([]string, 0, len(segments)) + for _, segment := range segments { + if segment == "" || segment == "." || segment == ".." { + return "", false + } + clean := slugpkg.Sanitize(segment) + if clean == "" || clean == "." || clean == ".." { + return "", false + } + sanitized = append(sanitized, clean) + } + return path.Join(sanitized...), true +} + +func isReservedContentSlug(slugPath string) bool { + if _, ok := reservedContentExactRoutes[slugPath]; ok { + return true + } + first := slugPath + if before, _, ok := strings.Cut(slugPath, "/"); ok { + first = before + } + _, ok := reservedContentRoutes[first] + return ok +} + +func contentDir(rawPath string) string { + if isURLString(rawPath) { + parsed, err := url.Parse(rawPath) + if err != nil { + return rawPath + } + parsed.Path = path.Dir(parsed.Path) + return strings.TrimRight(parsed.String(), "/") + } + return filepath.Dir(rawPath) +} + +func canonicalContentPath(loader *contentLoader, rawPath string) string { + if isURLString(rawPath) { + parsed, err := url.Parse(rawPath) + if err != nil { + return rawPath + } + parsed.Fragment = "" + parsed.RawQuery = "" + parsed.Path = path.Clean(parsed.Path) + return parsed.String() + } + if loader != nil { + if resolved, err := loader.resolveLocalPath(rawPath); err == nil { + return resolved + } + } + abs, err := filepath.Abs(rawPath) + if err != nil { + return filepath.Clean(rawPath) + } + return filepath.Clean(abs) +} + +func shouldSkipContentHref(raw string) bool { + trimmed := strings.TrimSpace(raw) + if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "/") { + return true + } + if strings.HasPrefix(strings.ToLower(trimmed), "mailto:") || strings.HasPrefix(strings.ToLower(trimmed), "data:") { + return true + } + parsed, err := url.Parse(trimmed) + return err == nil && parsed.Scheme != "" +} + +func safeContentAssetName(raw string) string { + base := path.Base(strings.TrimSpace(raw)) + if before, _, ok := strings.Cut(base, "?"); ok { + base = before + } + if before, _, ok := strings.Cut(base, "#"); ok { + base = before + } + ext := path.Ext(base) + stem := strings.TrimSuffix(base, ext) + stem = strings.Map(func(r rune) rune { + switch { + case unicode.IsLetter(r), unicode.IsDigit(r): + return unicode.ToLower(r) + case r == '-', r == '_': + return r + default: + return '-' + } + }, stem) + stem = strings.Trim(stem, "-_") + if stem == "" { + stem = "asset" + } + return slugpkg.Bound(stem) + strings.ToLower(ext) +} + +func disambiguateContentAssetName(name string, index int) string { + if index < 2 { + return name + } + ext := path.Ext(name) + stem := strings.TrimSuffix(name, ext) + if stem == "" { + stem = "asset" + } + return stem + "-" + strconv.Itoa(index) + ext +} + +func visibleContentPlaceholder(message string) string { + return "\n\n> " + message + "\n\n" +} + +func relativeContentHref(fromPageHref, targetHref string) string { + depth := contentPageDepth(fromPageHref) + if depth <= 0 { + return targetHref + } + return path.Clean(strings.Repeat("../", depth) + targetHref) +} + +func isContentNotFound(err error, result pageLoadResult) bool { + return result.NotFound || errors.Is(err, errContentNotFound) +} + +func (pp *PrintingPress) warnContentPage(message, context string, err error) { + if pp == nil || pp.engineConfig == nil || pp.engineConfig.Logger == nil { + return + } + args := []any{"context", context} + if err != nil { + args = append(args, "error", err) + } + pp.engineConfig.Logger.Warn("printingpress: "+message, args...) +} + +func contentPagesForNav(pages []*ppmodel.ContentPage) []*ppmodel.ContentPage { + if len(pages) == 0 { + return nil + } + result := make([]*ppmodel.ContentPage, 0, len(pages)) + for _, page := range pages { + if page == nil || page.Hidden { + continue + } + result = append(result, &ppmodel.ContentPage{ + Title: page.Title, + Label: page.Label, + Slug: page.Slug, + Href: page.Href, + Description: page.Description, + Order: page.Order, + }) + } + return result +} + +func hasContentPagesForNav(pages []*ppmodel.ContentPage) bool { + for _, page := range pages { + if page != nil && !page.Hidden { + return true + } + } + return false +} + +func contentPageDepth(href string) int { + dir := path.Dir(href) + if dir == "." || dir == "/" { + return 0 + } + return strings.Count(strings.Trim(dir, "/"), "/") + 1 +} diff --git a/printingpress/content_pages_test.go b/printingpress/content_pages_test.go new file mode 100644 index 0000000..e24dcfa --- /dev/null +++ b/printingpress/content_pages_test.go @@ -0,0 +1,421 @@ +// Copyright 2024-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// https://pb33f.io +// SPDX-License-Identifier: Apache-2.0 + +package printingpress + +import ( + "encoding/json" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + + ppmodel "github.com/pb33f/doctor/printingpress/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestContentPages_DiscoverRenderWriteAndStayOutOfLLM(t *testing.T) { + restoreLogger := silenceDefaultLogger() + defer restoreLogger() + + root := t.TempDir() + outputDir := t.TempDir() + specPath := filepath.Join(root, "openapi.yaml") + writeFile(t, specPath, minimalContentPageSpec()) + writeFile(t, filepath.Join(root, "_partials", "intro.md"), "---\ntitle: Partial Title\n---\nReusable *partial*.\n") + writeFile(t, filepath.Join(root, "images", "diagram.svg"), ``) + writeFile(t, filepath.Join(root, "about.md"), `--- +title: About The API +label: About +order: 5 +description: Team onboarding notes. +--- + +Root content unique to custom docs. + +{{}} + +[Read the guide](docs/guide.md#install) + +![Diagram](images/diagram.svg) +`) + writeFile(t, filepath.Join(root, "docs", "guide.md"), `--- +title: Service Guide +label: Guide +slug: guides/setup +order: 10 +--- + +## Install + +Use the generated API pages in parallel with service code. + +[Auth](../auth.md) + +![Nested diagram](../images/diagram.svg) +`) + writeFile(t, filepath.Join(root, "auth.md"), `--- +title: Auth +slug: index +order: 15 +--- + +Auth details. +`) + writeFile(t, filepath.Join(root, "security.md"), `--- +title: Security +order: 15 +--- + +Security details. +`) + writeFile(t, filepath.Join(root, "faq.md"), `--- +title: FAQ +hidden: true +--- + +Hidden FAQ content. +`) + writeFile(t, filepath.Join(root, "webhooks.md"), `Webhooks guide content.`) + + specBytes, err := os.ReadFile(specPath) + require.NoError(t, err) + pp, err := CreatePrintingPressFromBytes(specBytes, &PrintingPressConfig{ + BasePath: root, + SpecPath: specPath, + OutputDir: outputDir, + EnableContentPages: true, + }) + require.NoError(t, err) + + site, err := pp.PressModel() + require.NoError(t, err) + require.NotNil(t, site) + assert.Empty(t, site.Warnings, "custom page warnings stay log-only") + + about := requireContentPage(t, site.ContentPages, "about") + assert.Equal(t, "about.html", about.Href) + assert.Contains(t, about.BodyHTML, "Root content unique to custom docs.") + assert.Contains(t, about.BodyHTML, "Reusable partial.") + assert.NotContains(t, about.BodyHTML, "Partial Title") + assert.Contains(t, about.BodyHTML, `href="guides/setup.html#install"`) + assert.Contains(t, about.BodyHTML, `src="assets/docs/about/diagram.svg"`) + + guide := requireContentPage(t, site.ContentPages, "guides/setup") + assert.Equal(t, "guides/setup.html", guide.Href) + assert.Contains(t, guide.BodyHTML, `href="../auth.html"`) + assert.Contains(t, guide.BodyHTML, `src="../assets/docs/guides/setup/diagram.svg"`) + auth := requireContentPage(t, site.ContentPages, "auth") + security := requireContentPage(t, site.ContentPages, "security") + assert.Less(t, contentPageIndex(site.ContentPages, auth.Slug), contentPageIndex(site.ContentPages, security.Slug)) + faq := requireContentPage(t, site.ContentPages, "faq") + assert.True(t, faq.Hidden) + webhooksGuide := requireContentPage(t, site.ContentPages, "webhooks-guide") + assert.Equal(t, "Webhooks Guide", webhooksGuide.Title) + + navPayload := buildSharedHydrationPayload(site) + assert.NotContains(t, navPayload.Attributes["pp-nav"]["data-pages"], "bodyHtml") + assert.NotContains(t, navPayload.Attributes["pp-nav"]["data-pages"], "Root content unique") + var navPages []map[string]any + require.NoError(t, json.Unmarshal([]byte(navPayload.Attributes["pp-nav"]["data-pages"]), &navPages)) + navSlugs := make([]string, 0, len(navPages)) + for _, page := range navPages { + navSlugs = append(navSlugs, page["slug"].(string)) + } + assert.Contains(t, navSlugs, "about") + assert.Contains(t, navSlugs, "guides/setup") + assert.Contains(t, navSlugs, "webhooks-guide") + assert.NotContains(t, navSlugs, "faq") + + jsonOutputDir := t.TempDir() + require.NoError(t, PrintJSONArtifacts(site, jsonOutputDir)) + bundleData := readFile(t, filepath.Join(jsonOutputDir, "bundle.json")) + assert.Contains(t, bundleData, `"contentPages"`) + assert.Contains(t, bundleData, `"bodyHtml"`) + manifestData := readFile(t, filepath.Join(jsonOutputDir, "manifest.json")) + var manifest ArtifactManifest + require.NoError(t, json.Unmarshal([]byte(manifestData), &manifest)) + for _, artifact := range manifest.Artifacts { + assert.NotEqual(t, "content", artifact.Kind) + assert.NotEqual(t, "about.html", artifact.Path) + } + assert.NoFileExists(t, filepath.Join(jsonOutputDir, "about.html")) + + _, err = pp.PrintHTML() + require.NoError(t, err) + assert.FileExists(t, filepath.Join(outputDir, "about.html")) + assert.FileExists(t, filepath.Join(outputDir, "guides.html")) + assert.FileExists(t, filepath.Join(outputDir, "guides", "setup.html")) + assert.FileExists(t, filepath.Join(outputDir, "faq.html")) + assert.FileExists(t, filepath.Join(outputDir, "webhooks-guide.html")) + assert.FileExists(t, filepath.Join(outputDir, "assets", "docs", "about", "diagram.svg")) + assert.FileExists(t, filepath.Join(outputDir, "assets", "docs", "guides", "setup", "diagram.svg")) + + aboutHTML := readFile(t, filepath.Join(outputDir, "about.html")) + assert.Contains(t, aboutHTML, `data-active="content/about"`) + assert.Contains(t, aboutHTML, ``) + assert.Contains(t, aboutHTML, `GUIDES`) + assert.Contains(t, aboutHTML, `
`) + assert.NotContains(t, aboutHTML, `pp-description pp-content-page-body`) + assert.Contains(t, aboutHTML, `href="guides/setup.html#install"`) + assert.Contains(t, aboutHTML, `src="assets/docs/about/diagram.svg"`) + assert.Contains(t, aboutHTML, "Team onboarding notes.") + guideHTML := readFile(t, filepath.Join(outputDir, "guides", "setup.html")) + assert.Contains(t, guideHTML, `href="../auth.html"`) + assert.Contains(t, guideHTML, `src="../assets/docs/guides/setup/diagram.svg"`) + guidesHTML := readFile(t, filepath.Join(outputDir, "guides.html")) + assert.Contains(t, guidesHTML, `

Guides

`) + assert.Contains(t, guidesHTML, `GUIDES`) + assert.Contains(t, guidesHTML, `href="about.html"`) + assert.Contains(t, guidesHTML, `href="guides/setup.html"`) + assert.Contains(t, guidesHTML, `href="webhooks-guide.html"`) + assert.NotContains(t, guidesHTML, `href="faq.html"`) + + _, err = pp.PrintLLM() + require.NoError(t, err) + llmFull := readFile(t, filepath.Join(outputDir, "llms-full.txt")) + assert.NotContains(t, llmFull, "Root content unique to custom docs.") + assert.NotContains(t, llmFull, "Webhooks guide content.") +} + +func TestContentPages_RequireExplicitEnable(t *testing.T) { + root := t.TempDir() + specPath := filepath.Join(root, "openapi.yaml") + writeFile(t, specPath, minimalContentPageSpec()) + writeFile(t, filepath.Join(root, "about.md"), "Internal notes should not publish by default.\n") + + specBytes, err := os.ReadFile(specPath) + require.NoError(t, err) + pp, err := CreatePrintingPressFromBytes(specBytes, &PrintingPressConfig{ + BasePath: root, + SpecPath: specPath, + }) + require.NoError(t, err) + + site, err := pp.PressModel() + require.NoError(t, err) + assert.Empty(t, site.ContentPages) +} + +func TestContentPages_ImageBasenameCollisionsReuseOrDisambiguate(t *testing.T) { + restoreLogger := silenceDefaultLogger() + defer restoreLogger() + + root := t.TempDir() + specPath := filepath.Join(root, "openapi.yaml") + writeFile(t, specPath, minimalContentPageSpec()) + writeFile(t, filepath.Join(root, "images", "logo.png"), "light") + writeFile(t, filepath.Join(root, "images", "dark", "logo.png"), "dark") + writeFile(t, filepath.Join(root, "about.md"), `--- +title: About +--- + +![Logo](images/logo.png) +![Logo again](images/logo.png) +![Dark logo](images/dark/logo.png) +`) + + specBytes, err := os.ReadFile(specPath) + require.NoError(t, err) + pp, err := CreatePrintingPressFromBytes(specBytes, &PrintingPressConfig{ + BasePath: root, + SpecPath: specPath, + EnableContentPages: true, + }) + require.NoError(t, err) + + site, err := pp.PressModel() + require.NoError(t, err) + about := requireContentPage(t, site.ContentPages, "about") + require.Len(t, about.Assets, 2) + assert.Equal(t, "assets/docs/about/logo.png", about.Assets[0].Href) + assert.Equal(t, "assets/docs/about/logo-2.png", about.Assets[1].Href) + assert.Equal(t, 2, strings.Count(about.BodyHTML, `src="assets/docs/about/logo.png"`)) + assert.Contains(t, about.BodyHTML, `src="assets/docs/about/logo-2.png"`) +} + +func TestContentPages_InvalidFrontMatterKeepsOriginalBodyAndPartialsSkipCodeFences(t *testing.T) { + restoreLogger := silenceDefaultLogger() + defer restoreLogger() + + root := t.TempDir() + specPath := filepath.Join(root, "openapi.yaml") + writeFile(t, specPath, minimalContentPageSpec()) + writeFile(t, filepath.Join(root, "_partials", "intro.md"), "Reusable partial.\n") + writeFile(t, filepath.Join(root, "about.md"), "---\n"+ + "## This is a markdown thematic break, not front matter.\n"+ + "---\n\n"+ + "Top section stays visible.\n\n"+ + "```md\n"+ + "{{}}\n"+ + "```\n\n"+ + "{{}}\n") + + specBytes, err := os.ReadFile(specPath) + require.NoError(t, err) + pp, err := CreatePrintingPressFromBytes(specBytes, &PrintingPressConfig{ + BasePath: root, + SpecPath: specPath, + EnableContentPages: true, + }) + require.NoError(t, err) + + site, err := pp.PressModel() + require.NoError(t, err) + about := requireContentPage(t, site.ContentPages, "about") + assert.Contains(t, about.BodyHTML, "Top section stays visible.") + assert.Contains(t, about.BodyHTML, "This is a markdown thematic break") + assert.Equal(t, 1, strings.Count(about.BodyHTML, "Reusable partial.")) + assert.Contains(t, about.BodyHTML, "{{<partial") +} + +func TestContentPages_ShadowedDocsLinkRewritesToRootWinner(t *testing.T) { + restoreLogger := silenceDefaultLogger() + defer restoreLogger() + + root := t.TempDir() + specPath := filepath.Join(root, "openapi.yaml") + writeFile(t, specPath, minimalContentPageSpec()) + writeFile(t, filepath.Join(root, "about.md"), "See [Guide](docs/guide.md).\n") + writeFile(t, filepath.Join(root, "guide.md"), "Root guide wins.\n") + writeFile(t, filepath.Join(root, "docs", "guide.md"), "Ignored guide.\n") + + specBytes, err := os.ReadFile(specPath) + require.NoError(t, err) + pp, err := CreatePrintingPressFromBytes(specBytes, &PrintingPressConfig{ + BasePath: root, + SpecPath: specPath, + EnableContentPages: true, + }) + require.NoError(t, err) + + site, err := pp.PressModel() + require.NoError(t, err) + about := requireContentPage(t, site.ContentPages, "about") + assert.Contains(t, about.BodyHTML, `href="guide.html"`) + assert.NotContains(t, about.BodyHTML, `href="docs/guide.md"`) +} + +func TestContentPages_ImageSymlinkEscapeIsNotCopied(t *testing.T) { + restoreLogger := silenceDefaultLogger() + defer restoreLogger() + + root := t.TempDir() + outputDir := t.TempDir() + specPath := filepath.Join(root, "openapi.yaml") + writeFile(t, specPath, minimalContentPageSpec()) + writeFile(t, filepath.Join(root, "about.md"), `--- +title: About +--- + +![Escaping image](images/escape.svg) +`) + outside := t.TempDir() + writeFile(t, filepath.Join(outside, "escape.svg"), ``) + require.NoError(t, os.MkdirAll(filepath.Join(root, "images"), 0o755)) + if err := os.Symlink(filepath.Join(outside, "escape.svg"), filepath.Join(root, "images", "escape.svg")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + specBytes, err := os.ReadFile(specPath) + require.NoError(t, err) + pp, err := CreatePrintingPressFromBytes(specBytes, &PrintingPressConfig{ + BasePath: root, + SpecPath: specPath, + OutputDir: outputDir, + EnableContentPages: true, + }) + require.NoError(t, err) + + site, err := pp.PressModel() + require.NoError(t, err) + about := requireContentPage(t, site.ContentPages, "about") + assert.Contains(t, about.BodyHTML, `src="images/escape.svg"`) + assert.Empty(t, about.Assets) + + _, err = pp.PrintHTML() + require.NoError(t, err) + assert.NoFileExists(t, filepath.Join(outputDir, "assets", "docs", "about", "escape.svg")) +} + +func TestContentPageContext_RemoteSpecKeepsConfiguredLocalBasePath(t *testing.T) { + root := t.TempDir() + pp := &PrintingPress{engineConfig: &pressEngineConfig{ + ContentDiscoveryEnabled: true, + ContentBasePath: root, + ContentSpecPath: "https://example.com/apis/openapi.yaml", + }} + + ctx := pp.newContentPageContext() + require.NotNil(t, ctx) + assert.Equal(t, root, ctx.root) + assert.Equal(t, filepath.Join(root, "docs"), ctx.docsRoot) +} + +func TestContentPages_GuidesIndexRouteOnlyReservesExactSlug(t *testing.T) { + assert.True(t, isReservedContentSlug("guides")) + assert.True(t, isReservedContentSlug("services/users/index")) + assert.True(t, isReservedContentSlug("assets/docs/about/logo.png")) + assert.False(t, isReservedContentSlug("guides/setup")) +} + +func minimalContentPageSpec() string { + return `openapi: 3.1.0 +info: + title: Content Page Test API + version: 1.0.0 +paths: + /ping: + get: + operationId: getPing + summary: Ping + responses: + "200": + description: ok +` +} + +func requireContentPage(t *testing.T, pages []*ppmodel.ContentPage, slug string) *ppmodel.ContentPage { + t.Helper() + for _, page := range pages { + if page != nil && page.Slug == slug { + return page + } + } + require.Failf(t, "missing content page", "slug %q not found", slug) + return nil +} + +func contentPageIndex(pages []*ppmodel.ContentPage, slug string) int { + for i, page := range pages { + if page != nil && page.Slug == slug { + return i + } + } + return -1 +} + +func writeFile(t *testing.T, path string, body string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(body), 0o644)) +} + +func readFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err) + return string(data) +} + +func silenceDefaultLogger() func() { + previous := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil))) + return func() { + slog.SetDefault(previous) + } +} diff --git a/printingpress/html_hydration.go b/printingpress/html_hydration.go index 32e3753..af69e23 100644 --- a/printingpress/html_hydration.go +++ b/printingpress/html_hydration.go @@ -145,6 +145,7 @@ func buildSharedHydrationPayload(site *ppmodel.Site) *htmlHydrationPayload { "data-nav": render.MustJSON(site.NavTags), "data-models": render.MustJSON(site.NavModelGroups), "data-webhooks": render.MustJSON(site.NavWebhooks), + "data-pages": render.MustJSON(contentPagesForNav(site.ContentPages)), }, }, SchemaRegistry: registry, diff --git a/printingpress/html_writer.go b/printingpress/html_writer.go index a5c7f49..da097d8 100644 --- a/printingpress/html_writer.go +++ b/printingpress/html_writer.go @@ -107,6 +107,10 @@ func estimateHTMLPageJobs(site *ppmodel.Site) int { if site.Root != nil { total++ } + total += len(site.ContentPages) + if len(site.ContentPages) > 0 { + total++ + } total += len(site.Operations) for typeSlug, pages := range site.Models { if typeSlug == typeSlugPathItems { @@ -254,6 +258,7 @@ func writeHTMLSiteDetailed(site *ppmodel.Site, outputDir, baseURL string, progre ArchiveExportURL: site.ArchiveExportURL, Footer: site.Footer, SharedAssetBaseURL: site.SharedAssetBaseURL, + HasContentPages: hasContentPagesForNav(site.ContentPages), } var jobs []htmlWriteJob @@ -283,6 +288,46 @@ func writeHTMLSiteDetailed(site *ppmodel.Site, outputDir, baseURL string, progre progressTracker.advance("preparing overview page") } + // Write convention-discovered content pages. + for _, page := range site.ContentPages { + if page == nil { + continue + } + assetPaths, err := writeContentPageAssets(resolvedOutputDir, page) + if err != nil { + return nil, err + } + staticPaths = append(staticPaths, assetPaths...) + p := *params + p.BaseURL = resolveBase(resolvedBaseURL, contentPageDepth(page.Href)) + p.ExtraCSS = []string{pppaths.StaticAsset(pppaths.FilePrintingPressIndexCSS)} + content := render.ContentPageTempl(page, p.BaseURL) + jobs = append(jobs, htmlWriteJob{ + path: filepath.Join(resolvedOutputDir, filepath.FromSlash(page.Href)), + pageTitle: fmt.Sprintf("%s - %s", page.Title, title), + activeSlug: "content/" + page.Slug, + params: p, + content: content, + }) + progressTracker.advance("preparing content pages") + } + + // Write generated guides index page. + if len(site.ContentPages) > 0 { + p := *params + p.BaseURL = resolvedBaseURL + p.ExtraCSS = []string{pppaths.StaticAsset(pppaths.FilePrintingPressIndexCSS)} + content := render.ContentIndexTempl(site.ContentPages, render.GuidesIndexBreadcrumb(), p.BaseURL) + jobs = append(jobs, htmlWriteJob{ + path: filepath.Join(resolvedOutputDir, filepath.FromSlash(pppaths.GuidesIndexHTML())), + pageTitle: "Guides - " + title, + activeSlug: "guides", + params: p, + content: content, + }) + progressTracker.advance("preparing guides index") + } + // Write operation pages (1 level deep: operations/) for _, op := range site.Operations { p := *params @@ -652,6 +697,7 @@ type pageParams struct { ArchiveExportURL string Footer *ppmodel.FooterConfig SharedAssetBaseURL string + HasContentPages bool } func writeTemplPage(path, pageTitle, activeSlug string, p *pageParams, content templ.Component) error { @@ -688,6 +734,7 @@ func writeTemplPage(path, pageTitle, activeSlug string, p *pageParams, content t ArchiveExportURL: p.ArchiveExportURL, Footer: p.Footer, SharedAssetBaseURL: p.SharedAssetBaseURL, + HasContentPages: p.HasContentPages, }, content) return layout.Render(context.Background(), f) } @@ -724,6 +771,27 @@ func resolveBase(baseURL string, depth int) string { return strings.Repeat("../", depth) } +func writeContentPageAssets(outputDir string, page *ppmodel.ContentPage) ([]string, error) { + if page == nil { + return nil, nil + } + var written []string + for _, asset := range page.Assets { + if asset == nil || asset.Href == "" { + continue + } + assetPath := filepath.Join(outputDir, filepath.FromSlash(asset.Href)) + if err := os.MkdirAll(filepath.Dir(assetPath), 0o755); err != nil { + return nil, fmt.Errorf("creating content asset directory: %w", err) + } + if err := os.WriteFile(assetPath, asset.Data, 0o644); err != nil { + return nil, fmt.Errorf("writing content asset %s: %w", asset.Href, err) + } + written = append(written, assetPath) + } + return written, nil +} + func copyEmbeddedStatic(outputDir string) ([]string, error) { return copyEmbeddedDir(pppaths.DirStatic, filepath.Join(outputDir, pppaths.DirStatic)) } diff --git a/printingpress/internal/pppaths/constants.go b/printingpress/internal/pppaths/constants.go index f626d81..4de13d0 100644 --- a/printingpress/internal/pppaths/constants.go +++ b/printingpress/internal/pppaths/constants.go @@ -24,6 +24,7 @@ const ( DirOperations = "operations" DirModels = "models" DirTags = "tags" + DirContent = "content" DirServices = "services" DirVersions = "versions" DirSpecs = "specs" @@ -35,18 +36,20 @@ const ( DirPages = "pages" DirViz = "viz" - FileIndexHTML = "index.html" - FileDiagnosticsHTML = "diagnostics.html" - FileIndexJSON = "index.json" - FileBundleJSON = "bundle.json" - FileManifestJSON = "manifest.json" - FileNavJSON = "nav.json" - FileLLMIndex = "llms.txt" - FileLLMFull = "llms-full.txt" - FileLLMOperations = "llms-operations.txt" - FileLLMModels = "llms-models.txt" - FileAgentsGuide = "AGENTS.md" - FileStateSQLite = ".printingpress-state.db" + FileIndexHTML = "index.html" + FileGuidesHTML = "guides.html" + FileDiagnosticsHTML = "diagnostics.html" + FileIndexJSON = "index.json" + FileBundleJSON = "bundle.json" + FileManifestJSON = "manifest.json" + FileNavJSON = "nav.json" + FileLLMIndex = "llms.txt" + FileLLMFull = "llms-full.txt" + FileLLMOperations = "llms-operations.txt" + FileLLMModels = "llms-models.txt" + FileAgentsGuide = "AGENTS.md" + FileStateSQLite = ".printingpress-state.db" + FileCatalogContentStateJSON = ".printingpress-catalog-content.json" FilePB33FThemeCSS = "pb33f-theme.css" FileCowboyComponentsCSS = "cowboy-components.css" @@ -120,6 +123,10 @@ func ModelsIndexHTML() string { return path.Join(DirModels, FileIndexHTML) } +func GuidesIndexHTML() string { + return FileGuidesHTML +} + func ModelTypeIndexHTML(typeSlug string) string { return path.Join(DirModels, typeSlug, FileIndexHTML) } @@ -140,6 +147,18 @@ func TagHTML(slug string) string { return path.Join(DirTags, slug+ExtHTML) } +func ContentPageHTML(slugPath string) string { + return path.Clean(slugPath) + ExtHTML +} + +func ContentPageDataBase(slugPath string) string { + return path.Join(PageDataDir(), DirContent, path.Clean(slugPath)) +} + +func ContentAsset(pageSlug, assetName string) string { + return path.Join(DirAssets, "docs", path.Clean(pageSlug), assetName) +} + func OperationPageDataBase(slug string) string { return path.Join(PageDataDir(), DirOperations, slug) } diff --git a/printingpress/json_artifacts.go b/printingpress/json_artifacts.go index 7e0e9e1..11577dc 100644 --- a/printingpress/json_artifacts.go +++ b/printingpress/json_artifacts.go @@ -22,6 +22,7 @@ type JSONBundle struct { Root *JSONRootPage `json:"root,omitempty"` Source *ppmodel.SourceRef `json:"source,omitempty"` Nav []*ppmodel.NavTag `json:"nav,omitempty"` + ContentPages []*ppmodel.ContentPage `json:"contentPages,omitempty"` ModelGroups []*ppmodel.NavModelGroup `json:"modelGroups,omitempty"` SchemaRegistry map[string]*ppmodel.SchemaRegistryEntry `json:"schemaRegistry,omitempty"` Operations []JSONArtifactEntry `json:"operations,omitempty"` @@ -123,6 +124,7 @@ func buildJSONBundle(site *ppmodel.Site) *JSONBundle { Root: buildJSONRootPage(site.Root), Source: site.Source, Nav: site.NavTags, + ContentPages: site.ContentPages, ModelGroups: site.NavModelGroups, SchemaRegistry: site.SchemaRegistry, Operations: buildOperationArtifactEntries(site.Operations, "operation"), diff --git a/printingpress/markdown.go b/printingpress/markdown.go index 441a886..0344c5a 100644 --- a/printingpress/markdown.go +++ b/printingpress/markdown.go @@ -25,11 +25,12 @@ func init() { mdRendererPlain = newMarkdownRenderer(false) } -func newMarkdownRenderer(includeMermaid bool) goldmark.Markdown { +func newMarkdownRenderer(includeMermaid bool, extraExtensions ...goldmark.Extender) goldmark.Markdown { extensions := []goldmark.Extender{extension.GFM} if includeMermaid { extensions = append(extensions, &mermaidExtension{}) } + extensions = append(extensions, extraExtensions...) extensions = append(extensions, highlighting.NewHighlighting( highlighting.WithStyle("dracula"), highlighting.WithFormatOptions( @@ -67,6 +68,18 @@ func renderMarkdown(md string, noMermaid bool) string { return buf.String() } +func renderMarkdownWithExtensions(md string, noMermaid bool, extraExtensions ...goldmark.Extender) string { + if md == "" { + return "" + } + renderer := newMarkdownRenderer(!noMermaid, extraExtensions...) + var buf bytes.Buffer + if err := renderer.Convert([]byte(md), &buf); err != nil { + return md + } + return buf.String() +} + func (pp *PrintingPress) renderMarkdown(md string) string { return renderMarkdown(md, pp.engineConfig.NoMermaid) } diff --git a/printingpress/markdown_content_rewrite.go b/printingpress/markdown_content_rewrite.go new file mode 100644 index 0000000..f3e1569 --- /dev/null +++ b/printingpress/markdown_content_rewrite.go @@ -0,0 +1,68 @@ +// Copyright 2024-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// https://pb33f.io +// SPDX-License-Identifier: Apache-2.0 + +package printingpress + +import ( + "strings" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +type contentRewriteExtension struct { + rewriteLink func(string) (string, bool) + rewriteImage func(string) (string, bool) +} + +func (e *contentRewriteExtension) Extend(m goldmark.Markdown) { + m.Parser().AddOptions(parser.WithASTTransformers( + util.Prioritized(&contentRewriteTransformer{ + rewriteLink: e.rewriteLink, + rewriteImage: e.rewriteImage, + }, 90), + )) +} + +type contentRewriteTransformer struct { + rewriteLink func(string) (string, bool) + rewriteImage func(string) (string, bool) +} + +func (t *contentRewriteTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) { + _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + switch node := n.(type) { + case *ast.Image: + if t.rewriteImage != nil { + if replacement, ok := t.rewriteImage(string(node.Destination)); ok { + node.Destination = []byte(replacement) + } + } + return ast.WalkContinue, nil + case *ast.Link: + if t.rewriteLink != nil { + if replacement, ok := t.rewriteLink(string(node.Destination)); ok { + node.Destination = []byte(replacement) + } + } + return ast.WalkContinue, nil + default: + return ast.WalkContinue, nil + } + }) +} + +func splitMarkdownHref(raw string) (target string, fragment string) { + target = strings.TrimSpace(raw) + if before, after, ok := strings.Cut(target, "#"); ok { + return before, "#" + after + } + return target, "" +} diff --git a/printingpress/model/catalog.go b/printingpress/model/catalog.go index 4b2e96d..ac620e1 100644 --- a/printingpress/model/catalog.go +++ b/printingpress/model/catalog.go @@ -23,14 +23,15 @@ type SiteVersionLink struct { // CatalogSite is the aggregate multi-spec documentation catalog. type CatalogSite struct { - Title string `json:"title,omitempty"` - Description string `json:"description,omitempty"` - ScanRoot string `json:"scanRoot,omitempty"` - OutputDir string `json:"-"` - BaseURL string `json:"-"` - AssetMode string `json:"-"` - Services []*CatalogService `json:"services,omitempty"` - Warnings []*BuildWarning `json:"warnings,omitempty"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + ScanRoot string `json:"scanRoot,omitempty"` + OutputDir string `json:"-"` + BaseURL string `json:"-"` + AssetMode string `json:"-"` + Services []*CatalogService `json:"services,omitempty"` + ContentPages []*ContentPage `json:"contentPages,omitempty"` + Warnings []*BuildWarning `json:"warnings,omitempty"` } // CatalogService represents one grouped service in the aggregate catalog. diff --git a/printingpress/model/models.go b/printingpress/model/models.go index 2e8a422..5164adf 100644 --- a/printingpress/model/models.go +++ b/printingpress/model/models.go @@ -19,6 +19,7 @@ import ( type Site struct { Root *RootPage Operations []*OperationPage + ContentPages []*ContentPage Models map[string][]*ModelPage // keyed by component type slug (e.g. "schemas") Webhooks []*OperationPage Diagnostics *DiagnosticsPage @@ -47,6 +48,31 @@ type Site struct { HeaderContext *SiteHeaderContext `json:"headerContext,omitempty"` } +// ContentPage is a convention-discovered Markdown page rendered alongside the +// generated API reference. +type ContentPage struct { + Title string `json:"title"` + Label string `json:"label,omitempty"` + Slug string `json:"slug"` + Href string `json:"href"` + Description string `json:"description,omitempty"` + SourcePath string `json:"-"` + SourceDir string `json:"-"` + SourceAlias []string `json:"-"` + Body string `json:"-"` + BodyHTML string `json:"bodyHtml,omitempty"` + Order int `json:"order,omitempty"` + Hidden bool `json:"hidden,omitempty"` + Assets []*ContentPageAsset `json:"-"` +} + +// ContentPageAsset is a local or remote image copied into the generated docs. +type ContentPageAsset struct { + Href string `json:"href"` + SourcePath string `json:"-"` + Data []byte `json:"-"` +} + // LLMOutputConfig controls LLM-oriented aggregate text artifacts for a site. type LLMOutputConfig struct { AggregateSpecSizeThresholdBytes int64 diff --git a/printingpress/press.go b/printingpress/press.go index c85e267..6b66402 100644 --- a/printingpress/press.go +++ b/printingpress/press.go @@ -26,39 +26,42 @@ import ( ) type pressEngineConfig struct { - DrDoc *doctormodel.DrDocument - Origins bundler.ComponentOriginMap - OutputDir string - BaseURL string - AssetMode string - Embedded bool - SharedAssetBaseURL string - Title string - Logger *slog.Logger - SpecFormat string // "yaml" or "json" — caller should set based on input format - SpecRoot string // root directory of the spec; absolute paths are made relative to this - SpecPath string // absolute local path to the root spec file when known - SpecLocation string // display path of the root spec file relative to SpecRoot when possible - SpecURL string // optional published source URL for the root spec file - SourceSizeBytes int64 // size of the root source bytes when known - NoMermaid bool // skip mermaid class diagram generation on model pages - NoExplorer bool // skip dependency explorer on model pages - BuildWarnings []*BuildWarning - BuildErrors []error // errors from libopenapi model building (surfaced as warnings on index page) - DeveloperMode bool - DocsExpiresAt string - ArchiveExportURL string - LintResults []*v3.RuleFunctionResult - OrphanResults []*v3.RuleFunctionResult - Footer *FooterConfig - MaxPatternRepeatBudget int - MaxGeneratedStringBytes int - MaxGeneratedMockBytes int - MaxMockDepth int - MaxMockNodes int - MaxMockProperties int - MaxMockRefExpansions int - MaxMockBytes int + DrDoc *doctormodel.DrDocument + Origins bundler.ComponentOriginMap + OutputDir string + BaseURL string + AssetMode string + Embedded bool + SharedAssetBaseURL string + Title string + Logger *slog.Logger + SpecFormat string // "yaml" or "json" — caller should set based on input format + SpecRoot string // root directory of the spec; absolute paths are made relative to this + SpecPath string // absolute local path to the root spec file when known + SpecLocation string // display path of the root spec file relative to SpecRoot when possible + SpecURL string // optional published source URL for the root spec file + ContentDiscoveryEnabled bool + ContentBasePath string + ContentSpecPath string + SourceSizeBytes int64 // size of the root source bytes when known + NoMermaid bool // skip mermaid class diagram generation on model pages + NoExplorer bool // skip dependency explorer on model pages + BuildWarnings []*BuildWarning + BuildErrors []error // errors from libopenapi model building (surfaced as warnings on index page) + DeveloperMode bool + DocsExpiresAt string + ArchiveExportURL string + LintResults []*v3.RuleFunctionResult + OrphanResults []*v3.RuleFunctionResult + Footer *FooterConfig + MaxPatternRepeatBudget int + MaxGeneratedStringBytes int + MaxGeneratedMockBytes int + MaxMockDepth int + MaxMockNodes int + MaxMockProperties int + MaxMockRefExpansions int + MaxMockBytes int LLMAggregateSpecSizeThresholdBytes int64 LLMMaxAggregateFileBytes int64 LLMGenerateMonoliths string @@ -386,6 +389,7 @@ func (pp *PrintingPress) pressSite() (*Site, error) { } pp.site.NoMermaid = pp.engineConfig.NoMermaid pp.site.Lite = pp.engineConfig.NoMermaid && pp.engineConfig.NoExplorer + pp.collectContentPages() return pp.site, nil } diff --git a/printingpress/render/bootstrap_scripts/shared_nav_cache.js b/printingpress/render/bootstrap_scripts/shared_nav_cache.js index 50ca5a6..869d1d4 100644 --- a/printingpress/render/bootstrap_scripts/shared_nav_cache.js +++ b/printingpress/render/bootstrap_scripts/shared_nav_cache.js @@ -127,6 +127,10 @@ return docHref('models/' + typeSlug + '/' + slug + '.html'); } + function contentPageActiveSlug(slug) { + return 'content/' + String(slug || ''); + } + function parseJSONAttr(raw) { if (Array.isArray(raw)) { return raw; @@ -314,14 +318,34 @@ return html; } + function renderContentPage(page, activeSlug) { + if (!page) { + return ''; + } + const classes = ['nav-page-link']; + if (contentPageActiveSlug(page.slug) === activeSlug) { + classes.push('active'); + } + return ( + "
  • " + + escapeHtml(page.label || page.title || page.slug || 'Untitled') + + '
  • ' + ); + } + function renderNavPreview(navAttrs, activeSlug) { if (!navAttrs) { return ''; } const tags = parseJSONAttr(navAttrs['data-nav']); + const pages = parseJSONAttr(navAttrs['data-pages']); const modelGroups = parseJSONAttr(navAttrs['data-models']); const webhooks = parseJSONAttr(navAttrs['data-webhooks']); - if (!tags.length && !modelGroups.length && !webhooks.length) { + if (!tags.length && !pages.length && !modelGroups.length && !webhooks.length) { return ''; } let html = @@ -340,6 +364,13 @@ escapeHtml(docHref('diagnostics.html')) + "'>DIAGNOSTICS"; } + if (pages.length) { + html += "'; + } if (tags.length) { html += "