Why: Agent-Document-Renderer main publishes company.default@1.1.0 with a declarative TOC (spec 04, acceptance 10 §4 §7) and keeps 1.0.0 loadable by version. The fixed defaultProfileVersion "1.0.0" no longer matched LoadCompanyDefaultContext (1.1.0), so every delivery failed with a profile mismatch. New documents must take the latest published version, while documents that already bind a published version keep rendering with exactly that version, because published rules are immutable and old versions remain retrievable (spec 08). Evidence: go vet ./... ok; go test -count=1 ./... ok (all packages); go run ./internal/docgen/internal/genrendereridentity -check ok; scripts/agent-exec validate --repo . ok; scripts/dm governance check --repo . ok; gofmt -l clean; git diff --cached --check clean. New tests cover the default binding (1.1.0, TOC present), pinned 1.0.0 (no TOC) and 1.1.0 for both a request pin and a persisted-Markdown replay, and refusal of unknown versions and other profile IDs before any profile load. Impact: docgen resolves the profile version from the persisted Markdown binding or the request, defaults new documents to profiles.LatestCompanyDefaultVersion, and loads it through LoadCompanyDefaultVersionContext. The legacy migration comparison records the declared TOC as an intentional corporate-layout difference (RDR-T10-CMP-002) and keeps it out of the body visible-text dimension. RendererIdentity is regenerated for 3f259990b5d4. Refs: unified renderer plan Task 13; renderer PR #7 Co-authored-by: Cursor <cursoragent@cursor.com>
580 lines
21 KiB
Go
580 lines
21 KiB
Go
package docgen
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
markdownrenderer "agent-document-renderer/markdown"
|
|
"agent-document-renderer/model"
|
|
"agent-document-renderer/profile"
|
|
"agent-document-renderer/profiles"
|
|
"agent-document-renderer/render"
|
|
"agent-document-renderer/word"
|
|
)
|
|
|
|
const (
|
|
WorkspaceProjectID = "workspace"
|
|
ManifestSchemaVersion = "backend-document-delivery/v1"
|
|
AdapterIdentity = RendererIdentity + "/word-adapter-v1"
|
|
defaultProfileID = "company.default"
|
|
defaultRenderCapacity = 4
|
|
)
|
|
|
|
var renderCapacity = make(chan struct{}, defaultRenderCapacity)
|
|
|
|
var (
|
|
ErrRDMRequired = errors.New("docgen: controlled or project document requires RDM")
|
|
ErrProfileMismatch = errors.New("docgen: organization-default profile mismatch")
|
|
ErrResourceLimit = errors.New("docgen: resource limit exceeded")
|
|
)
|
|
|
|
type Artifact struct {
|
|
Name string
|
|
MIME string
|
|
SHA256 string
|
|
Bytes []byte
|
|
}
|
|
|
|
type Request struct {
|
|
DocumentID string
|
|
Title string
|
|
DocumentType string
|
|
Language string
|
|
Markdown []byte
|
|
Mode render.Mode
|
|
FrozenAt time.Time
|
|
RequestedBy string
|
|
ProfileID string
|
|
ProfileVersion string
|
|
Controlled bool
|
|
ProjectDocument bool
|
|
LegacySource []byte
|
|
Limits Limits
|
|
}
|
|
|
|
type Limits struct {
|
|
MaxMarkdownBytes int
|
|
MaxNodes int
|
|
MaxDepth int
|
|
}
|
|
|
|
type DiagnosticError struct {
|
|
Stage string
|
|
Code string
|
|
Diagnostics []render.Diagnostic
|
|
Err error
|
|
}
|
|
|
|
func (failure *DiagnosticError) Error() string {
|
|
if failure == nil {
|
|
return "docgen: diagnostic failure"
|
|
}
|
|
return fmt.Sprintf("docgen: %s: %s: %v", failure.Stage, failure.Code, failure.Err)
|
|
}
|
|
|
|
func (failure *DiagnosticError) Unwrap() error {
|
|
if failure == nil {
|
|
return nil
|
|
}
|
|
return failure.Err
|
|
}
|
|
|
|
type ComparisonDimension struct {
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
LegacyHash string `json:"legacyHash,omitempty"`
|
|
SharedHash string `json:"sharedHash,omitempty"`
|
|
ExceptionID string `json:"exceptionId,omitempty"`
|
|
Detail string `json:"detail,omitempty"`
|
|
}
|
|
|
|
type ComparisonReceipt struct {
|
|
Enabled bool `json:"enabled"`
|
|
Assessment string `json:"assessment,omitempty"`
|
|
LegacySHA256 string `json:"legacySha256,omitempty"`
|
|
SharedSHA256 string `json:"sharedSha256,omitempty"`
|
|
Dimensions []ComparisonDimension `json:"dimensions,omitempty"`
|
|
IntentionalDifferences []string `json:"intentionalDifferences,omitempty"`
|
|
AccidentalDifferences []string `json:"accidentalDifferences,omitempty"`
|
|
Unassessed []string `json:"unassessed,omitempty"`
|
|
}
|
|
|
|
type RenderOptions struct {
|
|
MigrationComparison bool
|
|
}
|
|
|
|
type ManifestArtifact struct {
|
|
Name string `json:"name"`
|
|
MIME string `json:"mime"`
|
|
Size int `json:"size"`
|
|
SHA256 string `json:"sha256"`
|
|
}
|
|
|
|
type ProfileReceipt struct {
|
|
ProfileID string `json:"profileId"`
|
|
Version string `json:"version"`
|
|
SnapshotHash string `json:"snapshotHash"`
|
|
Candidate bool `json:"candidate"`
|
|
ReviewStatus string `json:"reviewStatus"`
|
|
PublicationAllowed bool `json:"publicationAllowed"`
|
|
Unverified []string `json:"unverified"`
|
|
ProfileSHA256 string `json:"profileSha256"`
|
|
AssetManifestSHA256 string `json:"assetManifestSha256"`
|
|
ReviewSHA256 string `json:"reviewSha256"`
|
|
}
|
|
|
|
type Manifest struct {
|
|
SchemaVersion string `json:"schemaVersion"`
|
|
RendererIdentity string `json:"rendererIdentity"`
|
|
AdapterIdentity string `json:"adapterIdentity"`
|
|
SelectionSource string `json:"selectionSource"`
|
|
Profile ProfileReceipt `json:"profile"`
|
|
DocumentRootID string `json:"documentRootId"`
|
|
Artifacts []ManifestArtifact `json:"artifacts"`
|
|
Renderer render.Manifest `json:"renderer"`
|
|
Diagnostics []render.Diagnostic `json:"diagnostics,omitempty"`
|
|
NodeAnchors map[string]string `json:"nodeAnchors"`
|
|
Comparison ComparisonReceipt `json:"comparison"`
|
|
}
|
|
|
|
type Delivery struct {
|
|
Document model.CanonicalDocument
|
|
Profile ProfileReceipt
|
|
Renderer render.Manifest
|
|
Markdown Artifact
|
|
DOCX Artifact
|
|
Manifest Artifact
|
|
Archive Artifact
|
|
Comparison ComparisonReceipt
|
|
}
|
|
|
|
type PreparedDocument struct {
|
|
Document model.CanonicalDocument
|
|
Markdown Artifact
|
|
CanonicalHash string
|
|
}
|
|
|
|
// Prepare canonicalizes and validates authoritative Markdown without loading a
|
|
// profile or invoking the Word adapter. Durable callers persist its Markdown
|
|
// artifact before any rendering work.
|
|
func Prepare(ctx context.Context, request Request) (PreparedDocument, error) {
|
|
if err := contextErr(ctx); err != nil {
|
|
return PreparedDocument{}, err
|
|
}
|
|
if request.Controlled || request.ProjectDocument || request.Mode == render.ModeReview || request.Mode == render.ModeApproved {
|
|
return PreparedDocument{}, ErrRDMRequired
|
|
}
|
|
if request.Mode != render.ModeWorking && request.Mode != render.ModePreview {
|
|
return PreparedDocument{}, fmt.Errorf("docgen: unsupported ordinary workspace mode %q", request.Mode)
|
|
}
|
|
limits, err := normalizeLimits(request.Limits)
|
|
if err != nil {
|
|
return PreparedDocument{}, err
|
|
}
|
|
if len(request.Markdown) > limits.MaxMarkdownBytes {
|
|
return PreparedDocument{}, fmt.Errorf("%w: Markdown is %d bytes, limit is %d", ErrResourceLimit, len(request.Markdown), limits.MaxMarkdownBytes)
|
|
}
|
|
materialized := bytes.HasPrefix(bytes.TrimSpace(request.Markdown), []byte("---"))
|
|
var parsed markdownrenderer.Result
|
|
if materialized {
|
|
parsed, err = parseMarkdownContext(ctx, request.Markdown, false, limits)
|
|
if err != nil {
|
|
return PreparedDocument{}, classifyParseError(err)
|
|
}
|
|
if err := validateWorkspaceRoute(request, parsed.Metadata); err != nil {
|
|
return PreparedDocument{}, err
|
|
}
|
|
if _, err := profileVersionFor(request, &parsed.Metadata); err != nil {
|
|
return PreparedDocument{}, err
|
|
}
|
|
} else {
|
|
profileVersion, err := profileVersionFor(request, nil)
|
|
if err != nil {
|
|
return PreparedDocument{}, err
|
|
}
|
|
source, err := workspaceMarkdown(request, defaultProfileID, profileVersion)
|
|
if err != nil {
|
|
return PreparedDocument{}, err
|
|
}
|
|
if len(source) > limits.MaxMarkdownBytes {
|
|
return PreparedDocument{}, fmt.Errorf("%w: normalized Markdown source is %d bytes, limit is %d", ErrResourceLimit, len(source), limits.MaxMarkdownBytes)
|
|
}
|
|
parsed, err = parseMarkdownContext(ctx, source, true, limits)
|
|
if err != nil {
|
|
return PreparedDocument{}, classifyParseError(err)
|
|
}
|
|
}
|
|
if len(parsed.Markdown) > limits.MaxMarkdownBytes {
|
|
return PreparedDocument{}, fmt.Errorf("%w: materialized Markdown is %d bytes, limit is %d", ErrResourceLimit, len(parsed.Markdown), limits.MaxMarkdownBytes)
|
|
}
|
|
canonicalHash, err := parsed.Document.HashContext(ctx)
|
|
if err != nil {
|
|
return PreparedDocument{}, err
|
|
}
|
|
return PreparedDocument{
|
|
Document: parsed.Document, CanonicalHash: canonicalHash,
|
|
Markdown: artifact(safeBaseName(request.Title)+".md", "text/markdown; charset=utf-8", parsed.Markdown),
|
|
}, nil
|
|
}
|
|
|
|
type generatorDependencies struct {
|
|
loadProfile func(context.Context, string) (profiles.Bundle, error)
|
|
newRenderer func(*profile.AssetReader) render.Renderer
|
|
legacyBuild func(string, string) ([]byte, error)
|
|
}
|
|
|
|
func defaultGeneratorDependencies() generatorDependencies {
|
|
return generatorDependencies{
|
|
loadProfile: profiles.LoadCompanyDefaultVersionContext,
|
|
newRenderer: func(assets *profile.AssetReader) render.Renderer { return word.NewAdapter(assets) },
|
|
legacyBuild: legacyBuild,
|
|
}
|
|
}
|
|
|
|
func Generate(ctx context.Context, request Request) (Delivery, error) {
|
|
return generate(ctx, request, defaultGeneratorDependencies())
|
|
}
|
|
|
|
func generate(ctx context.Context, request Request, dependencies generatorDependencies) (Delivery, error) {
|
|
return generateWithOptions(ctx, request, RenderOptions{}, dependencies)
|
|
}
|
|
|
|
func GenerateWithOptions(ctx context.Context, request Request, options RenderOptions) (Delivery, error) {
|
|
return generateWithOptions(ctx, request, options, defaultGeneratorDependencies())
|
|
}
|
|
|
|
func generateWithOptions(ctx context.Context, request Request, options RenderOptions, dependencies generatorDependencies) (Delivery, error) {
|
|
if err := contextErr(ctx); err != nil {
|
|
return Delivery{}, err
|
|
}
|
|
if request.Controlled || request.ProjectDocument || request.Mode == render.ModeReview || request.Mode == render.ModeApproved {
|
|
return Delivery{}, ErrRDMRequired
|
|
}
|
|
if request.Mode != render.ModeWorking && request.Mode != render.ModePreview {
|
|
return Delivery{}, fmt.Errorf("docgen: unsupported ordinary workspace mode %q", request.Mode)
|
|
}
|
|
limits, err := normalizeLimits(request.Limits)
|
|
if err != nil {
|
|
return Delivery{}, err
|
|
}
|
|
if len(request.Markdown) > limits.MaxMarkdownBytes {
|
|
return Delivery{}, fmt.Errorf("%w: Markdown is %d bytes, limit is %d", ErrResourceLimit, len(request.Markdown), limits.MaxMarkdownBytes)
|
|
}
|
|
var parsed markdownrenderer.Result
|
|
var boundMetadata *markdownrenderer.Metadata
|
|
materialized := bytes.HasPrefix(bytes.TrimSpace(request.Markdown), []byte("---"))
|
|
if materialized {
|
|
parsed, err = parseMarkdownContext(ctx, request.Markdown, false, limits)
|
|
if err != nil {
|
|
return Delivery{}, classifyParseError(err)
|
|
}
|
|
if err := validateWorkspaceRoute(request, parsed.Metadata); err != nil {
|
|
return Delivery{}, err
|
|
}
|
|
boundMetadata = &parsed.Metadata
|
|
}
|
|
boundVersion, err := profileVersionFor(request, boundMetadata)
|
|
if err != nil {
|
|
return Delivery{}, err
|
|
}
|
|
bundle, err := dependencies.loadProfile(ctx, boundVersion)
|
|
if err != nil {
|
|
return Delivery{}, fmt.Errorf("docgen: load company default %s: %w", boundVersion, err)
|
|
}
|
|
profileID, profileVersion := bundle.Snapshot.ProfileIdentity()
|
|
if profileID != defaultProfileID || profileVersion != boundVersion {
|
|
return Delivery{}, fmt.Errorf("%w: loaded %s@%s, bound %s@%s", ErrProfileMismatch,
|
|
profileID, profileVersion, defaultProfileID, boundVersion)
|
|
}
|
|
if !materialized {
|
|
source, sourceErr := workspaceMarkdown(request, profileID, profileVersion)
|
|
if sourceErr != nil {
|
|
return Delivery{}, sourceErr
|
|
}
|
|
if len(source) > limits.MaxMarkdownBytes {
|
|
return Delivery{}, fmt.Errorf("%w: normalized Markdown source is %d bytes, limit is %d", ErrResourceLimit, len(source), limits.MaxMarkdownBytes)
|
|
}
|
|
parsed, err = parseMarkdownContext(ctx, source, true, limits)
|
|
if err != nil {
|
|
return Delivery{}, classifyParseError(err)
|
|
}
|
|
}
|
|
if len(parsed.Markdown) > limits.MaxMarkdownBytes {
|
|
return Delivery{}, fmt.Errorf("%w: materialized Markdown is %d bytes, limit is %d", ErrResourceLimit, len(parsed.Markdown), limits.MaxMarkdownBytes)
|
|
}
|
|
if err := validateWorkspaceMetadata(request, parsed.Metadata, profileID, profileVersion); err != nil {
|
|
return Delivery{}, err
|
|
}
|
|
if err := contextErr(ctx); err != nil {
|
|
return Delivery{}, err
|
|
}
|
|
if err := acquireRenderCapacity(ctx); err != nil {
|
|
return Delivery{}, err
|
|
}
|
|
result, err := dependencies.newRenderer(bundle.Assets).Render(ctx, render.Request{
|
|
Document: parsed.Document,
|
|
Profile: bundle.Snapshot,
|
|
Mode: request.Mode,
|
|
FrozenAt: request.FrozenAt,
|
|
RequestedBy: request.RequestedBy,
|
|
})
|
|
releaseRenderCapacity()
|
|
if err != nil {
|
|
code := "renderer_failed"
|
|
if strings.Contains(err.Error(), "asset") || strings.Contains(err.Error(), "hash") {
|
|
code = "asset_mismatch"
|
|
}
|
|
return Delivery{}, diagnosticFailure("render", code, err, result.Diagnostics)
|
|
}
|
|
base := safeBaseName(request.Title)
|
|
delivery := Delivery{
|
|
Document: parsed.Document, Renderer: result.Manifest,
|
|
Profile: ProfileReceipt{
|
|
ProfileID: profileID, Version: profileVersion, SnapshotHash: bundle.Snapshot.SnapshotHash(),
|
|
Candidate: bundle.Snapshot.State() != profile.StatePublished, ReviewStatus: bundle.Review.Status, PublicationAllowed: bundle.Review.PublicationAllowed,
|
|
Unverified: append([]string(nil), bundle.Review.Unverified...), ProfileSHA256: bundle.Review.ProfileSHA256,
|
|
AssetManifestSHA256: bundle.Review.AssetManifestSHA256, ReviewSHA256: bundle.Review.ReviewSHA256,
|
|
},
|
|
Markdown: artifact(base+".md", "text/markdown; charset=utf-8", parsed.Markdown),
|
|
DOCX: artifact(base+".docx", result.MIME, result.Artifact),
|
|
}
|
|
if options.MigrationComparison {
|
|
comparisonSource := request.LegacySource
|
|
if len(comparisonSource) == 0 {
|
|
comparisonSource = request.Markdown
|
|
}
|
|
legacy, err := dependencies.legacyBuild(request.Title, string(comparisonSource))
|
|
if err != nil {
|
|
return Delivery{}, fmt.Errorf("docgen: legacy migration comparison: %w", err)
|
|
}
|
|
delivery.Comparison = compareLegacyAndShared(request.Title, comparisonSource, parsed.Document, legacy, delivery.DOCX.Bytes)
|
|
}
|
|
manifest := Manifest{
|
|
SchemaVersion: ManifestSchemaVersion, RendererIdentity: RendererIdentity, AdapterIdentity: AdapterIdentity,
|
|
SelectionSource: "organization-default",
|
|
Profile: delivery.Profile,
|
|
DocumentRootID: parsed.Document.Root.ID,
|
|
Artifacts: []ManifestArtifact{
|
|
manifestArtifact(delivery.Markdown), manifestArtifact(delivery.DOCX),
|
|
},
|
|
Renderer: result.Manifest, Diagnostics: append([]render.Diagnostic(nil), result.Diagnostics...),
|
|
NodeAnchors: cloneStringMap(result.NodeAnchors), Comparison: delivery.Comparison,
|
|
}
|
|
manifestRaw, err := json.MarshalIndent(manifest, "", " ")
|
|
if err != nil {
|
|
return Delivery{}, fmt.Errorf("docgen: marshal manifest: %w", err)
|
|
}
|
|
manifestRaw = append(manifestRaw, '\n')
|
|
delivery.Manifest = artifact(base+".manifest.json", "application/json", manifestRaw)
|
|
archiveRaw, err := buildArchive(delivery.Markdown, delivery.DOCX, delivery.Manifest)
|
|
if err != nil {
|
|
return Delivery{}, err
|
|
}
|
|
delivery.Archive = artifact(base+".zip", "application/zip", archiveRaw)
|
|
return delivery, nil
|
|
}
|
|
|
|
func acquireRenderCapacity(ctx context.Context) error {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case renderCapacity <- struct{}{}:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func releaseRenderCapacity() { <-renderCapacity }
|
|
|
|
func parseMarkdownContext(ctx context.Context, source []byte, firstImport bool, limits Limits) (markdownrenderer.Result, error) {
|
|
return markdownrenderer.ParseContext(ctx, source, markdownrenderer.Options{
|
|
Controlled: true, FirstImport: firstImport, MaxBytes: limits.MaxMarkdownBytes,
|
|
MaxNodes: limits.MaxNodes, MaxDepth: limits.MaxDepth,
|
|
})
|
|
}
|
|
|
|
func classifyParseError(err error) error {
|
|
if errors.Is(err, markdownrenderer.ErrDocumentLimit) {
|
|
return fmt.Errorf("%w: %v", ErrResourceLimit, err)
|
|
}
|
|
code := "markdown_invalid"
|
|
switch {
|
|
case errors.Is(err, markdownrenderer.ErrUnknownMetadata):
|
|
code = "unknown_metadata"
|
|
case errors.Is(err, markdownrenderer.ErrHiddenHTML):
|
|
code = "unsafe_markdown"
|
|
case errors.Is(err, markdownrenderer.ErrUnsafeDirective) && strings.Contains(err.Error(), "unknown directive"):
|
|
code = "unknown_directive"
|
|
case errors.Is(err, markdownrenderer.ErrUnsafeDirective):
|
|
code = "unsafe_markdown"
|
|
}
|
|
return diagnosticFailure("parse", code, err, nil)
|
|
}
|
|
|
|
func diagnosticFailure(stage, code string, err error, diagnostics []render.Diagnostic) error {
|
|
cloned := append([]render.Diagnostic(nil), diagnostics...)
|
|
if len(cloned) == 0 {
|
|
cloned = []render.Diagnostic{{Code: code, Severity: render.SeverityError, Message: err.Error()}}
|
|
}
|
|
return &DiagnosticError{Stage: stage, Code: code, Diagnostics: cloned, Err: err}
|
|
}
|
|
|
|
func normalizeLimits(input Limits) (Limits, error) {
|
|
if input.MaxMarkdownBytes < 0 || input.MaxNodes < 0 || input.MaxDepth < 0 {
|
|
return Limits{}, fmt.Errorf("%w: limits cannot be negative", ErrResourceLimit)
|
|
}
|
|
if input.MaxMarkdownBytes == 0 {
|
|
input.MaxMarkdownBytes = 2 << 20
|
|
}
|
|
if input.MaxNodes == 0 {
|
|
input.MaxNodes = 10_000
|
|
}
|
|
if input.MaxDepth == 0 {
|
|
input.MaxDepth = 64
|
|
}
|
|
return input, nil
|
|
}
|
|
|
|
func workspaceMarkdown(request Request, profileID, profileVersion string) ([]byte, error) {
|
|
if strings.TrimSpace(request.DocumentID) == "" || strings.TrimSpace(request.Title) == "" ||
|
|
strings.TrimSpace(request.DocumentType) == "" || strings.TrimSpace(request.Language) == "" {
|
|
return nil, errors.New("docgen: document ID, title, document type, and language are required")
|
|
}
|
|
if request.Mode == "" || request.FrozenAt.IsZero() || strings.TrimSpace(request.RequestedBy) == "" {
|
|
return nil, errors.New("docgen: mode, frozen time, and requester are required")
|
|
}
|
|
quote := func(value string) string {
|
|
raw, _ := json.Marshal(value)
|
|
return string(raw)
|
|
}
|
|
var header strings.Builder
|
|
header.WriteString("---\n")
|
|
fmt.Fprintf(&header, "documentId: %s\n", quote(request.DocumentID))
|
|
fmt.Fprintf(&header, "projectId: %s\n", quote(WorkspaceProjectID))
|
|
fmt.Fprintf(&header, "documentType: %s\n", quote(request.DocumentType))
|
|
fmt.Fprintf(&header, "title: %s\n", quote(request.Title))
|
|
fmt.Fprintf(&header, "language: %s\n", quote(request.Language))
|
|
fmt.Fprintf(&header, "profileId: %s\n", quote(profileID))
|
|
fmt.Fprintf(&header, "profileVersion: %s\n", quote(profileVersion))
|
|
header.WriteString("controlled: false\n---\n\n")
|
|
header.Write(bytes.TrimSpace(request.Markdown))
|
|
header.WriteByte('\n')
|
|
return []byte(header.String()), nil
|
|
}
|
|
|
|
func validateWorkspaceRoute(request Request, metadata markdownrenderer.Metadata) error {
|
|
if metadata.Controlled || metadata.ProjectID != WorkspaceProjectID {
|
|
return ErrRDMRequired
|
|
}
|
|
if metadata.DocumentID != request.DocumentID || metadata.DocumentType != request.DocumentType ||
|
|
metadata.Title != request.Title || metadata.Language != request.Language {
|
|
return errors.New("docgen: persisted Markdown metadata does not match the frozen request")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// profileVersionFor returns the company-default version a document renders
|
|
// with. Published versions are immutable, so a version the persisted Markdown
|
|
// or the request already binds is kept; only a new, unbound document takes the
|
|
// latest published version. metadata is nil until Markdown is materialized.
|
|
func profileVersionFor(request Request, metadata *markdownrenderer.Metadata) (string, error) {
|
|
requested := request.ProfileID != "" || request.ProfileVersion != ""
|
|
if requested && !publishedProfileVersion(request.ProfileID, request.ProfileVersion) {
|
|
return "", fmt.Errorf("%w: requested %s@%s is not a published %s version", ErrProfileMismatch,
|
|
request.ProfileID, request.ProfileVersion, defaultProfileID)
|
|
}
|
|
if metadata == nil {
|
|
if requested {
|
|
return request.ProfileVersion, nil
|
|
}
|
|
return profiles.LatestCompanyDefaultVersion, nil
|
|
}
|
|
if !publishedProfileVersion(metadata.ProfileID, metadata.ProfileVersion) {
|
|
return "", fmt.Errorf("%w: Markdown binds %s@%s, which is not a published %s version", ErrProfileMismatch,
|
|
metadata.ProfileID, metadata.ProfileVersion, defaultProfileID)
|
|
}
|
|
if requested && request.ProfileVersion != metadata.ProfileVersion {
|
|
return "", fmt.Errorf("%w: requested %s@%s, Markdown binds %s@%s", ErrProfileMismatch,
|
|
request.ProfileID, request.ProfileVersion, metadata.ProfileID, metadata.ProfileVersion)
|
|
}
|
|
return metadata.ProfileVersion, nil
|
|
}
|
|
|
|
func publishedProfileVersion(profileID, profileVersion string) bool {
|
|
return profileID == defaultProfileID && slices.Contains(profiles.CompanyDefaultVersions(), profileVersion)
|
|
}
|
|
|
|
func validateWorkspaceMetadata(request Request, metadata markdownrenderer.Metadata, profileID, profileVersion string) error {
|
|
if err := validateWorkspaceRoute(request, metadata); err != nil {
|
|
return err
|
|
}
|
|
if metadata.ProfileID != profileID || metadata.ProfileVersion != profileVersion {
|
|
return fmt.Errorf("%w: Markdown binds %s@%s, available %s@%s", ErrProfileMismatch,
|
|
metadata.ProfileID, metadata.ProfileVersion, profileID, profileVersion)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func artifact(name, mime string, raw []byte) Artifact {
|
|
sum := sha256.Sum256(raw)
|
|
// Callers transfer ownership of freshly produced buffers to the delivery.
|
|
return Artifact{Name: name, MIME: mime, SHA256: hex.EncodeToString(sum[:]), Bytes: raw}
|
|
}
|
|
|
|
func manifestArtifact(value Artifact) ManifestArtifact {
|
|
return ManifestArtifact{Name: value.Name, MIME: value.MIME, Size: len(value.Bytes), SHA256: value.SHA256}
|
|
}
|
|
|
|
func buildArchive(artifacts ...Artifact) ([]byte, error) {
|
|
items := append([]Artifact(nil), artifacts...)
|
|
sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name })
|
|
var buffer bytes.Buffer
|
|
archive := zip.NewWriter(&buffer)
|
|
for _, item := range items {
|
|
header := &zip.FileHeader{Name: item.Name, Method: zip.Deflate}
|
|
header.SetModTime(time.Date(1980, 1, 1, 0, 0, 0, 0, time.UTC))
|
|
header.SetMode(0o644)
|
|
writer, err := archive.CreateHeader(header)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("docgen: create archive entry %s: %w", item.Name, err)
|
|
}
|
|
if _, err := writer.Write(item.Bytes); err != nil {
|
|
return nil, fmt.Errorf("docgen: write archive entry %s: %w", item.Name, err)
|
|
}
|
|
}
|
|
if err := archive.Close(); err != nil {
|
|
return nil, fmt.Errorf("docgen: close delivery archive: %w", err)
|
|
}
|
|
return buffer.Bytes(), nil
|
|
}
|
|
|
|
func cloneStringMap(source map[string]string) map[string]string {
|
|
clone := make(map[string]string, len(source))
|
|
for key, value := range source {
|
|
clone[key] = value
|
|
}
|
|
return clone
|
|
}
|
|
|
|
func safeBaseName(title string) string {
|
|
return strings.TrimSuffix(SuggestName(title), ".docx")
|
|
}
|
|
|
|
func contextErr(ctx context.Context) error {
|
|
select {
|
|
case <-ctx.Done():
|
|
return fmt.Errorf("docgen: render canceled: %w", ctx.Err())
|
|
default:
|
|
return nil
|
|
}
|
|
}
|