1
go.mod
1
go.mod
@@ -22,6 +22,7 @@ require (
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/vektah/gqlparser/v2 v2.5.27
|
||||
github.com/xuri/excelize/v2 v2.9.1
|
||||
github.com/yuin/goldmark v1.7.12
|
||||
go.gearno.de/crypto/uuid v0.1.0
|
||||
go.gearno.de/kit v0.0.0-20250623163305-45b4f6905899
|
||||
go.gearno.de/x/ref v0.0.0-20240502200927-d74926fcb14c
|
||||
|
||||
2
go.sum
2
go.sum
@@ -187,6 +187,8 @@ github.com/xuri/excelize/v2 v2.9.1 h1:VdSGk+rraGmgLHGFaGG9/9IWu1nj4ufjJ7uwMDtj8Q
|
||||
github.com/xuri/excelize/v2 v2.9.1/go.mod h1:x7L6pKz2dvo9ejrRuD8Lnl98z4JLt0TGAwjhW+EiP8s=
|
||||
github.com/xuri/nfp v0.0.1 h1:MDamSGatIvp8uOmDP8FnmjuQpu90NzdJxo7242ANR9Q=
|
||||
github.com/xuri/nfp v0.0.1/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||
github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY=
|
||||
github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
go.gearno.de/crypto/uuid v0.1.0 h1:94BYg7GYItJ6yYZ1GJayb3VYhI9/FjxuR1nFaduR4hE=
|
||||
go.gearno.de/crypto/uuid v0.1.0/go.mod h1:fnIIvKO9QnsyLO3ZJLJT3r8KZv/p0FOeT5eZKilYWXg=
|
||||
go.gearno.de/kit v0.0.0-20250623163305-45b4f6905899 h1:g44W/Fhm5bW7fhMqr874dJKOuHHsRhv6Tr3h6yHLcqY=
|
||||
|
||||
107
pkg/docgen/generator.go
Normal file
107
pkg/docgen/generator.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package docgen
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"html"
|
||||
"html/template"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/yuin/goldmark"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed template.html
|
||||
htmlTemplateContent string
|
||||
|
||||
templateFuncs = template.FuncMap{
|
||||
"now": func() time.Time { return time.Now() },
|
||||
"eq": func(a, b string) bool { return a == b },
|
||||
"string": func(v fmt.Stringer) string { return v.String() },
|
||||
"lower": func(s string) string { return strings.ToLower(s) },
|
||||
"classificationString": func(c Classification) string { return string(c) },
|
||||
"formatContent": func(content string) template.HTML {
|
||||
md := goldmark.New()
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := md.Convert([]byte(content), &buf); err != nil {
|
||||
return template.HTML(fmt.Sprintf("<p>%s</p>", html.EscapeString(content)))
|
||||
}
|
||||
return template.HTML(buf.String())
|
||||
},
|
||||
}
|
||||
|
||||
documentTemplate = template.Must(template.New("document").Funcs(templateFuncs).Parse(htmlTemplateContent))
|
||||
)
|
||||
|
||||
type (
|
||||
Generator struct{}
|
||||
|
||||
Classification string
|
||||
|
||||
DocumentData struct {
|
||||
Title string
|
||||
Content string
|
||||
Version int
|
||||
Classification Classification
|
||||
Approver string
|
||||
Description string
|
||||
PublishedAt *time.Time
|
||||
PublishedBy *time.Time
|
||||
Signatures []SignatureData
|
||||
}
|
||||
|
||||
SignatureData struct {
|
||||
SignedBy string
|
||||
SignedAt *time.Time
|
||||
State coredata.DocumentVersionSignatureState
|
||||
RequestedAt time.Time
|
||||
RequestedBy string
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
ClassificationPublic Classification = "PUBLIC"
|
||||
ClassificationInternal Classification = "INTERNAL"
|
||||
ClassificationConfidential Classification = "CONFIDENTIAL"
|
||||
ClassificationSecret Classification = "SECRET"
|
||||
)
|
||||
|
||||
func NewGenerator() *Generator {
|
||||
return &Generator{}
|
||||
}
|
||||
|
||||
func (g *Generator) GenerateHTML(data DocumentData) ([]byte, error) {
|
||||
data.Title = html.EscapeString(data.Title)
|
||||
data.Approver = html.EscapeString(data.Approver)
|
||||
data.Description = html.EscapeString(data.Description)
|
||||
|
||||
for i := range data.Signatures {
|
||||
data.Signatures[i].SignedBy = html.EscapeString(data.Signatures[i].SignedBy)
|
||||
data.Signatures[i].RequestedBy = html.EscapeString(data.Signatures[i].RequestedBy)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := documentTemplate.Execute(&buf, data); err != nil {
|
||||
return nil, fmt.Errorf("failed to execute template: %w", err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
439
pkg/docgen/generator_test.go
Normal file
439
pkg/docgen/generator_test.go
Normal file
@@ -0,0 +1,439 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package docgen
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewGenerator(t *testing.T) {
|
||||
generator := NewGenerator()
|
||||
assert.NotNil(t, generator)
|
||||
assert.IsType(t, &Generator{}, generator)
|
||||
}
|
||||
|
||||
func TestGenerateHTML(t *testing.T) {
|
||||
generator := NewGenerator()
|
||||
now := time.Now()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
data DocumentData
|
||||
wantContains []string
|
||||
wantNotContains []string
|
||||
}{
|
||||
{
|
||||
name: "basic document with all fields",
|
||||
data: DocumentData{
|
||||
Title: "Test Document",
|
||||
Content: "# Main Title\n\nThis is **bold** text with *italic* formatting.",
|
||||
Version: 1,
|
||||
Classification: ClassificationPublic,
|
||||
Approver: "John Doe",
|
||||
Description: "Test document description",
|
||||
PublishedAt: &now,
|
||||
Signatures: []SignatureData{
|
||||
{
|
||||
SignedBy: "Alice Smith",
|
||||
SignedAt: &now,
|
||||
State: coredata.DocumentVersionSignatureStateSigned,
|
||||
RequestedAt: now,
|
||||
RequestedBy: "Bob Johnson",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantContains: []string{
|
||||
"Test Document",
|
||||
"<h1>Main Title</h1>",
|
||||
"<strong>bold</strong>",
|
||||
"<em>italic</em>",
|
||||
"<td>1</td>",
|
||||
"PUBLIC",
|
||||
"John Doe",
|
||||
"Test document description",
|
||||
"Alice Smith",
|
||||
"Bob Johnson",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "document with HTML characters that need escaping",
|
||||
data: DocumentData{
|
||||
Title: "Test & <Script> Title",
|
||||
Content: "Normal markdown content",
|
||||
Approver: "John <script>alert('xss')</script> Doe",
|
||||
Description: "Description with & symbols and <tags>",
|
||||
Signatures: []SignatureData{
|
||||
{
|
||||
SignedBy: "Alice & <Bob>",
|
||||
RequestedBy: "Carol <script>",
|
||||
State: coredata.DocumentVersionSignatureStateRequested,
|
||||
},
|
||||
},
|
||||
},
|
||||
wantContains: []string{
|
||||
"Test &amp; &lt;Script&gt; Title",
|
||||
"John &lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt; Doe",
|
||||
"Description with &amp; symbols and &lt;tags&gt;",
|
||||
"Alice &amp; &lt;Bob&gt;",
|
||||
"Carol &lt;script&gt;",
|
||||
},
|
||||
wantNotContains: []string{
|
||||
"<script>alert('xss')</script>",
|
||||
"Test & <Script> Title",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "document with markdown content",
|
||||
data: DocumentData{
|
||||
Title: "Markdown Test",
|
||||
Content: "## Section 1\n\n- Item 1\n- Item 2\n\n**Bold text** and *italic text*\n\n```code block```",
|
||||
},
|
||||
wantContains: []string{
|
||||
"<h2>Section 1</h2>",
|
||||
"<ul>",
|
||||
"<li>Item 1</li>",
|
||||
"<li>Item 2</li>",
|
||||
"</ul>",
|
||||
"<strong>Bold text</strong>",
|
||||
"<em>italic text</em>",
|
||||
"<code>code block</code>",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "document with all classification types",
|
||||
data: DocumentData{
|
||||
Title: "Classification Test",
|
||||
Classification: ClassificationConfidential,
|
||||
},
|
||||
wantContains: []string{"CONFIDENTIAL"},
|
||||
},
|
||||
{
|
||||
name: "empty document",
|
||||
data: DocumentData{},
|
||||
wantContains: []string{
|
||||
"<!DOCTYPE html>",
|
||||
"<html",
|
||||
"</html>",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "document with multiple signatures in different states",
|
||||
data: DocumentData{
|
||||
Title: "Signatures Test",
|
||||
Signatures: []SignatureData{
|
||||
{
|
||||
SignedBy: "Signer 1",
|
||||
SignedAt: &now,
|
||||
State: coredata.DocumentVersionSignatureStateSigned,
|
||||
RequestedAt: now,
|
||||
RequestedBy: "Requester 1",
|
||||
},
|
||||
{
|
||||
SignedBy: "Signer 2",
|
||||
State: coredata.DocumentVersionSignatureStateRequested,
|
||||
RequestedAt: now,
|
||||
RequestedBy: "Requester 2",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantContains: []string{
|
||||
"Signer 1",
|
||||
"Signer 2",
|
||||
"Requester 1",
|
||||
"Requester 2",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := generator.GenerateHTML(tt.data)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, result)
|
||||
|
||||
resultStr := string(result)
|
||||
|
||||
// Check that all expected content is present
|
||||
for _, want := range tt.wantContains {
|
||||
assert.Contains(t, resultStr, want, "Expected content not found: %s", want)
|
||||
}
|
||||
|
||||
// Check that unwanted content is not present
|
||||
for _, wantNot := range tt.wantNotContains {
|
||||
assert.NotContains(t, resultStr, wantNot, "Unwanted content found: %s", wantNot)
|
||||
}
|
||||
|
||||
// Basic HTML structure validation
|
||||
assert.Contains(t, resultStr, "<!DOCTYPE html>")
|
||||
assert.Contains(t, resultStr, "<html")
|
||||
assert.Contains(t, resultStr, "</html>")
|
||||
assert.Contains(t, resultStr, "<head>")
|
||||
assert.Contains(t, resultStr, "</head>")
|
||||
assert.Contains(t, resultStr, "<body>")
|
||||
assert.Contains(t, resultStr, "</body>")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateHTML_ErrorHandling(t *testing.T) {
|
||||
generator := NewGenerator()
|
||||
|
||||
// Test with data that should not cause errors
|
||||
data := DocumentData{
|
||||
Title: "Valid Document",
|
||||
Content: "Valid content",
|
||||
}
|
||||
|
||||
result, err := generator.GenerateHTML(data)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, result)
|
||||
}
|
||||
|
||||
func TestTemplateFunctions(t *testing.T) {
|
||||
t.Run("now function", func(t *testing.T) {
|
||||
nowFunc := templateFuncs["now"].(func() time.Time)
|
||||
result := nowFunc()
|
||||
assert.True(t, time.Since(result) < time.Second)
|
||||
})
|
||||
|
||||
t.Run("eq function", func(t *testing.T) {
|
||||
eqFunc := templateFuncs["eq"].(func(string, string) bool)
|
||||
assert.True(t, eqFunc("test", "test"))
|
||||
assert.False(t, eqFunc("test", "other"))
|
||||
})
|
||||
|
||||
t.Run("lower function", func(t *testing.T) {
|
||||
lowerFunc := templateFuncs["lower"].(func(string) string)
|
||||
assert.Equal(t, "hello world", lowerFunc("HELLO WORLD"))
|
||||
assert.Equal(t, "test", lowerFunc("Test"))
|
||||
})
|
||||
|
||||
t.Run("classificationString function", func(t *testing.T) {
|
||||
classFunc := templateFuncs["classificationString"].(func(Classification) string)
|
||||
assert.Equal(t, "PUBLIC", classFunc(ClassificationPublic))
|
||||
assert.Equal(t, "CONFIDENTIAL", classFunc(ClassificationConfidential))
|
||||
})
|
||||
|
||||
t.Run("formatContent function", func(t *testing.T) {
|
||||
formatFunc := templateFuncs["formatContent"].(func(string) template.HTML)
|
||||
|
||||
// Test markdown conversion
|
||||
result := formatFunc("**bold** text")
|
||||
assert.Contains(t, string(result), "<strong>bold</strong>")
|
||||
|
||||
// Test basic text
|
||||
result = formatFunc("simple text")
|
||||
assert.Contains(t, string(result), "<p>simple text</p>")
|
||||
|
||||
// Test empty content - goldmark produces empty output for empty input
|
||||
result = formatFunc("")
|
||||
// Empty content should produce empty result from goldmark
|
||||
assert.Equal(t, template.HTML(""), result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestClassificationConstants(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
classification Classification
|
||||
expected string
|
||||
}{
|
||||
{"public", ClassificationPublic, "PUBLIC"},
|
||||
{"internal", ClassificationInternal, "INTERNAL"},
|
||||
{"confidential", ClassificationConfidential, "CONFIDENTIAL"},
|
||||
{"secret", ClassificationSecret, "SECRET"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, string(tt.classification))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLEscaping(t *testing.T) {
|
||||
generator := NewGenerator()
|
||||
|
||||
dangerousData := DocumentData{
|
||||
Title: "<script>alert('xss')</script>",
|
||||
Approver: "User & <Company>",
|
||||
Description: "Text with 'quotes' & \"double quotes\"",
|
||||
Signatures: []SignatureData{
|
||||
{
|
||||
SignedBy: "<malicious>tag",
|
||||
RequestedBy: "User & Company",
|
||||
State: coredata.DocumentVersionSignatureStateRequested,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := generator.GenerateHTML(dangerousData)
|
||||
require.NoError(t, err)
|
||||
|
||||
resultStr := string(result)
|
||||
|
||||
// Verify dangerous content is escaped
|
||||
assert.NotContains(t, resultStr, "<script>alert('xss')</script>")
|
||||
assert.NotContains(t, resultStr, "<malicious>tag")
|
||||
assert.Contains(t, resultStr, "&lt;script&gt;")
|
||||
assert.Contains(t, resultStr, "&amp;")
|
||||
assert.Contains(t, resultStr, "&#39;")
|
||||
}
|
||||
|
||||
func TestMarkdownRendering(t *testing.T) {
|
||||
generator := NewGenerator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
markdown string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "headers",
|
||||
markdown: "# H1\n## H2\n### H3",
|
||||
want: []string{"<h1>H1</h1>", "<h2>H2</h2>", "<h3>H3</h3>"},
|
||||
},
|
||||
{
|
||||
name: "emphasis",
|
||||
markdown: "**bold** and *italic*",
|
||||
want: []string{"<strong>bold</strong>", "<em>italic</em>"},
|
||||
},
|
||||
{
|
||||
name: "lists",
|
||||
markdown: "- Item 1\n- Item 2",
|
||||
want: []string{"<ul>", "<li>Item 1</li>", "<li>Item 2</li>", "</ul>"},
|
||||
},
|
||||
{
|
||||
name: "paragraphs",
|
||||
markdown: "Paragraph 1\n\nParagraph 2",
|
||||
want: []string{"<p>Paragraph 1</p>", "<p>Paragraph 2</p>"},
|
||||
},
|
||||
{
|
||||
name: "code",
|
||||
markdown: "`inline code` and\n```\ncode block\n```",
|
||||
want: []string{"<code>inline code</code>", "<pre><code>code block"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
data := DocumentData{
|
||||
Title: "Markdown Test",
|
||||
Content: tt.markdown,
|
||||
}
|
||||
|
||||
result, err := generator.GenerateHTML(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
resultStr := string(result)
|
||||
for _, want := range tt.want {
|
||||
assert.Contains(t, resultStr, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentVersionSignatureStates(t *testing.T) {
|
||||
generator := NewGenerator()
|
||||
now := time.Now()
|
||||
|
||||
states := []coredata.DocumentVersionSignatureState{
|
||||
coredata.DocumentVersionSignatureStateRequested,
|
||||
coredata.DocumentVersionSignatureStateSigned,
|
||||
// Add other states if they exist
|
||||
}
|
||||
|
||||
for _, state := range states {
|
||||
t.Run(string(state), func(t *testing.T) {
|
||||
data := DocumentData{
|
||||
Title: "State Test",
|
||||
Signatures: []SignatureData{
|
||||
{
|
||||
SignedBy: "Test User",
|
||||
State: state,
|
||||
RequestedAt: now,
|
||||
RequestedBy: "Requester",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := generator.GenerateHTML(data)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLargeContent(t *testing.T) {
|
||||
generator := NewGenerator()
|
||||
|
||||
// Create a large markdown content
|
||||
var largeContent strings.Builder
|
||||
for i := 0; i < 1000; i++ {
|
||||
largeContent.WriteString("# Section ")
|
||||
largeContent.WriteString(string(rune('A' + i%26)))
|
||||
largeContent.WriteString("\n\nThis is a paragraph with **bold** and *italic* text.\n\n")
|
||||
largeContent.WriteString("- List item 1\n- List item 2\n- List item 3\n\n")
|
||||
}
|
||||
|
||||
data := DocumentData{
|
||||
Title: "Large Document",
|
||||
Content: largeContent.String(),
|
||||
}
|
||||
|
||||
result, err := generator.GenerateHTML(data)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, result)
|
||||
assert.True(t, len(result) > 10000) // Should be reasonably large
|
||||
}
|
||||
|
||||
func BenchmarkGenerateHTML(b *testing.B) {
|
||||
generator := NewGenerator()
|
||||
now := time.Now()
|
||||
|
||||
data := DocumentData{
|
||||
Title: "Benchmark Document",
|
||||
Content: "# Title\n\nThis is **bold** text with *italic* formatting.\n\n- Item 1\n- Item 2",
|
||||
Version: 1,
|
||||
Classification: ClassificationPublic,
|
||||
Approver: "John Doe",
|
||||
Description: "Benchmark test document",
|
||||
PublishedAt: &now,
|
||||
Signatures: []SignatureData{
|
||||
{
|
||||
SignedBy: "Alice Smith",
|
||||
SignedAt: &now,
|
||||
State: coredata.DocumentVersionSignatureStateSigned,
|
||||
RequestedAt: now,
|
||||
RequestedBy: "Bob Johnson",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := generator.GenerateHTML(data)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
367
pkg/docgen/template.html
Normal file
367
pkg/docgen/template.html
Normal file
@@ -0,0 +1,367 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}}</title>
|
||||
<style>
|
||||
/* A4 Page Setup for printing */
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 2.5cm;
|
||||
@bottom-right {
|
||||
content: "Page " counter(page) " of " counter(pages);
|
||||
font-family: 'Times New Roman', Times, serif;
|
||||
font-size: 9pt;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Times New Roman', Times, serif;
|
||||
font-size: 11pt;
|
||||
line-height: 1.4;
|
||||
color: #000;
|
||||
margin: 0;
|
||||
padding: 20px 0;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
/* Document container that simulates A4 pages */
|
||||
.document-container {
|
||||
width: 21cm;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* Individual page sections */
|
||||
.page-section {
|
||||
padding: 2.5cm;
|
||||
position: relative;
|
||||
min-height: 24.7cm; /* A4 height minus padding */
|
||||
border-bottom: 2px dashed #ddd;
|
||||
page-break-after: always;
|
||||
}
|
||||
|
||||
.page-section:last-child {
|
||||
border-bottom: none;
|
||||
page-break-after: auto;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.document-header {
|
||||
border-bottom: 1px solid #333;
|
||||
padding-bottom: 15px;
|
||||
margin-bottom: 25px;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
|
||||
.document-title {
|
||||
font-size: 18pt;
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
margin: 0 0 10px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.document-meta {
|
||||
background: #f9f9f9;
|
||||
padding: 12px;
|
||||
border: 1px solid #ddd;
|
||||
margin: 15px 0;
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
.meta-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.meta-table td {
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid #eee;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.meta-table td:first-child {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.classification {
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Content styling with page break controls */
|
||||
.document-content {
|
||||
orphans: 3;
|
||||
widows: 3;
|
||||
}
|
||||
|
||||
.document-content h1 {
|
||||
font-size: 14pt;
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
margin: 20px 0 12px 0;
|
||||
border-bottom: 1px solid #ccc;
|
||||
padding-bottom: 4px;
|
||||
page-break-after: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.document-content h2 {
|
||||
font-size: 13pt;
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
margin: 18px 0 10px 0;
|
||||
page-break-after: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.document-content h3 {
|
||||
font-size: 12pt;
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
margin: 16px 0 8px 0;
|
||||
page-break-after: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.document-content h4 {
|
||||
font-size: 11pt;
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
margin: 14px 0 6px 0;
|
||||
page-break-after: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.document-content p {
|
||||
margin-bottom: 12px;
|
||||
text-align: justify;
|
||||
orphans: 3;
|
||||
widows: 3;
|
||||
}
|
||||
|
||||
.document-content ul,
|
||||
.document-content ol {
|
||||
padding-left: 20px;
|
||||
margin: 12px 0;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.document-content li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.document-content strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.document-content em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Force page breaks at strategic points */
|
||||
.document-content h2:nth-of-type(3),
|
||||
.document-content h2:nth-of-type(5),
|
||||
.document-content h2:nth-of-type(7),
|
||||
.document-content h2:nth-of-type(9) {
|
||||
page-break-before: always;
|
||||
}
|
||||
|
||||
/* Signatures section */
|
||||
.signatures-section {
|
||||
margin-top: 30px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #ccc;
|
||||
page-break-before: always;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.signatures-title {
|
||||
font-size: 13pt;
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
margin-bottom: 15px;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
|
||||
.signatures-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 9pt;
|
||||
margin-top: 10px;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.signatures-table th,
|
||||
.signatures-table td {
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.signatures-table th {
|
||||
background: #f5f5f5;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.signatures-table tr {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.signature-signed {
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.signature-requested {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #ddd;
|
||||
font-size: 8pt;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Prevent bad page breaks */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
page-break-after: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
p, li {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
table, .signatures-section {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
background: white;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.document-container {
|
||||
box-shadow: none;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.page-section {
|
||||
box-shadow: none;
|
||||
border-bottom: none;
|
||||
padding: 0;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@media screen and (max-width: 21cm) {
|
||||
.document-container {
|
||||
width: 95%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="document-container">
|
||||
<div class="page-section">
|
||||
<div class="document-header">
|
||||
<h1 class="document-title">{{.Title}}</h1>
|
||||
|
||||
<div class="document-meta">
|
||||
<table class="meta-table">
|
||||
<tr>
|
||||
<td>Classification:</td>
|
||||
<td>
|
||||
<span class="classification">{{.Classification | classificationString}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Approver:</td>
|
||||
<td>{{.Approver}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Description:</td>
|
||||
<td>{{.Description}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Version:</td>
|
||||
<td>{{.Version}}</td>
|
||||
</tr>
|
||||
{{- if .PublishedAt}}
|
||||
<tr>
|
||||
<td>Published:</td>
|
||||
<td>{{.PublishedAt.Format "January 2, 2006"}}{{if .PublishedBy}} ({{.PublishedBy.Format "January 2, 2006"}}){{end}}</td>
|
||||
</tr>
|
||||
{{- end}}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="document-content">
|
||||
{{.Content | formatContent}}
|
||||
</div>
|
||||
|
||||
{{- if .Signatures}}
|
||||
<div class="signatures-section">
|
||||
<h2 class="signatures-title">Document Signatures</h2>
|
||||
<table class="signatures-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Signatory</th>
|
||||
<th>Status</th>
|
||||
<th>Requested Date</th>
|
||||
<th>Signed Date</th>
|
||||
<th>Requested By</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{- range .Signatures}}
|
||||
<tr>
|
||||
<td>{{.SignedBy}}</td>
|
||||
<td>
|
||||
{{- if eq (string .State) "SIGNED"}}
|
||||
<span class="signature-signed">{{string .State}}</span>
|
||||
{{- else}}
|
||||
<span class="signature-requested">{{string .State}}</span>
|
||||
{{- end}}
|
||||
</td>
|
||||
<td>{{.RequestedAt.Format "Jan 2, 2006"}}</td>
|
||||
<td>
|
||||
{{- if .SignedAt}}
|
||||
{{.SignedAt.Format "Jan 2, 2006"}}
|
||||
{{- else}}
|
||||
-
|
||||
{{- end}}
|
||||
</td>
|
||||
<td>{{.RequestedBy}}</td>
|
||||
</tr>
|
||||
{{- end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{- end}}
|
||||
|
||||
<div class="footer">
|
||||
<p>Document generated on {{now.Format "January 2, 2006 at 3:04 PM"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user