Add docgen package

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2025-07-02 12:10:24 +02:00
parent 47b65b10bd
commit 1025bcb509
5 changed files with 916 additions and 0 deletions

1
go.mod
View File

@@ -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
View File

@@ -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
View 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
}

View 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;amp; &amp;lt;Script&amp;gt; Title",
"John &amp;lt;script&amp;gt;alert(&amp;#39;xss&amp;#39;)&amp;lt;/script&amp;gt; Doe",
"Description with &amp;amp; symbols and &amp;lt;tags&amp;gt;",
"Alice &amp;amp; &amp;lt;Bob&amp;gt;",
"Carol &amp;lt;script&amp;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, "&amp;lt;script&amp;gt;")
assert.Contains(t, resultStr, "&amp;amp;")
assert.Contains(t, resultStr, "&amp;#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
View 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>