@openpolicy/renderers 0.0.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/package.json +20 -0
- package/src/html.test.ts +166 -0
- package/src/html.ts +65 -0
- package/src/index.test.ts +28 -0
- package/src/index.ts +63 -0
- package/src/markdown.test.ts +230 -0
- package/src/markdown.ts +70 -0
- package/src/pdf.test.ts +34 -0
- package/src/pdf.ts +158 -0
- package/tsconfig.json +8 -0
package/CHANGELOG.md
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openpolicy/renderers",
|
|
3
|
+
"version": "0.0.14",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": "./src/index.ts"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"check-types": "tsc --noEmit",
|
|
10
|
+
"test": "bun test"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"pdfkit": "^0.18.0"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@openpolicy/core": "workspace:*",
|
|
17
|
+
"@openpolicy/tooling": "workspace:*",
|
|
18
|
+
"@types/pdfkit": "^0.17.5"
|
|
19
|
+
}
|
|
20
|
+
}
|
package/src/html.test.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import type { Document } from "@openpolicy/core";
|
|
3
|
+
import { renderHTML } from "./html";
|
|
4
|
+
|
|
5
|
+
function doc(sections: Document["sections"]): Document {
|
|
6
|
+
return { type: "document", policyType: "privacy", sections };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
test("renders heading as <h2>", () => {
|
|
10
|
+
const result = renderHTML(
|
|
11
|
+
doc([
|
|
12
|
+
{
|
|
13
|
+
type: "section",
|
|
14
|
+
id: "s1",
|
|
15
|
+
content: [{ type: "heading", value: "Introduction" }],
|
|
16
|
+
},
|
|
17
|
+
]),
|
|
18
|
+
);
|
|
19
|
+
expect(result).toContain("<h2>Introduction</h2>");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("renders paragraph as <p>", () => {
|
|
23
|
+
const result = renderHTML(
|
|
24
|
+
doc([
|
|
25
|
+
{
|
|
26
|
+
type: "section",
|
|
27
|
+
id: "s1",
|
|
28
|
+
content: [
|
|
29
|
+
{
|
|
30
|
+
type: "paragraph",
|
|
31
|
+
children: [{ type: "text", value: "Hello world" }],
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
]),
|
|
36
|
+
);
|
|
37
|
+
expect(result).toContain("<p>Hello world</p>");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("renders bold as <strong>", () => {
|
|
41
|
+
const result = renderHTML(
|
|
42
|
+
doc([
|
|
43
|
+
{
|
|
44
|
+
type: "section",
|
|
45
|
+
id: "s1",
|
|
46
|
+
content: [
|
|
47
|
+
{
|
|
48
|
+
type: "paragraph",
|
|
49
|
+
children: [{ type: "bold", value: "Important" }],
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
]),
|
|
54
|
+
);
|
|
55
|
+
expect(result).toContain("<strong>Important</strong>");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("renders link as <a>", () => {
|
|
59
|
+
const result = renderHTML(
|
|
60
|
+
doc([
|
|
61
|
+
{
|
|
62
|
+
type: "section",
|
|
63
|
+
id: "s1",
|
|
64
|
+
content: [
|
|
65
|
+
{
|
|
66
|
+
type: "paragraph",
|
|
67
|
+
children: [
|
|
68
|
+
{ type: "link", href: "https://example.com", value: "here" },
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
]),
|
|
74
|
+
);
|
|
75
|
+
expect(result).toContain('<a href="https://example.com">here</a>');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("renders heading with level 3 as <h3>", () => {
|
|
79
|
+
const result = renderHTML(
|
|
80
|
+
doc([
|
|
81
|
+
{
|
|
82
|
+
type: "section",
|
|
83
|
+
id: "s1",
|
|
84
|
+
content: [{ type: "heading", level: 3, value: "Sub-section" }],
|
|
85
|
+
},
|
|
86
|
+
]),
|
|
87
|
+
);
|
|
88
|
+
expect(result).toContain("<h3>Sub-section</h3>");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("renders ordered list as <ol>/<li>", () => {
|
|
92
|
+
const result = renderHTML(
|
|
93
|
+
doc([
|
|
94
|
+
{
|
|
95
|
+
type: "section",
|
|
96
|
+
id: "s1",
|
|
97
|
+
content: [
|
|
98
|
+
{
|
|
99
|
+
type: "list",
|
|
100
|
+
ordered: true,
|
|
101
|
+
items: [
|
|
102
|
+
{
|
|
103
|
+
type: "listItem",
|
|
104
|
+
children: [{ type: "text", value: "First" }],
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
type: "listItem",
|
|
108
|
+
children: [{ type: "text", value: "Second" }],
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
]),
|
|
115
|
+
);
|
|
116
|
+
expect(result).toContain("<ol>");
|
|
117
|
+
expect(result).toContain("<li>First</li>");
|
|
118
|
+
expect(result).toContain("<li>Second</li>");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("renders italic as <em>", () => {
|
|
122
|
+
const result = renderHTML(
|
|
123
|
+
doc([
|
|
124
|
+
{
|
|
125
|
+
type: "section",
|
|
126
|
+
id: "s1",
|
|
127
|
+
content: [
|
|
128
|
+
{
|
|
129
|
+
type: "paragraph",
|
|
130
|
+
children: [{ type: "italic", value: "emphasis" }],
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
]),
|
|
135
|
+
);
|
|
136
|
+
expect(result).toContain("<em>emphasis</em>");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("renders list as <ul>/<li>", () => {
|
|
140
|
+
const result = renderHTML(
|
|
141
|
+
doc([
|
|
142
|
+
{
|
|
143
|
+
type: "section",
|
|
144
|
+
id: "s1",
|
|
145
|
+
content: [
|
|
146
|
+
{
|
|
147
|
+
type: "list",
|
|
148
|
+
items: [
|
|
149
|
+
{
|
|
150
|
+
type: "listItem",
|
|
151
|
+
children: [{ type: "text", value: "Alpha" }],
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
type: "listItem",
|
|
155
|
+
children: [{ type: "text", value: "Beta" }],
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
},
|
|
161
|
+
]),
|
|
162
|
+
);
|
|
163
|
+
expect(result).toContain("<ul>");
|
|
164
|
+
expect(result).toContain("<li>Alpha</li>");
|
|
165
|
+
expect(result).toContain("<li>Beta</li>");
|
|
166
|
+
});
|
package/src/html.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Document,
|
|
3
|
+
InlineNode,
|
|
4
|
+
ListItemNode,
|
|
5
|
+
ListNode,
|
|
6
|
+
} from "@openpolicy/core";
|
|
7
|
+
|
|
8
|
+
function escapeHtml(str: string): string {
|
|
9
|
+
return str
|
|
10
|
+
.replace(/&/g, "&")
|
|
11
|
+
.replace(/</g, "<")
|
|
12
|
+
.replace(/>/g, ">")
|
|
13
|
+
.replace(/"/g, """);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function renderInline(node: InlineNode): string {
|
|
17
|
+
switch (node.type) {
|
|
18
|
+
case "text":
|
|
19
|
+
return escapeHtml(node.value);
|
|
20
|
+
case "bold":
|
|
21
|
+
return `<strong>${escapeHtml(node.value)}</strong>`;
|
|
22
|
+
case "italic":
|
|
23
|
+
return `<em>${escapeHtml(node.value)}</em>`;
|
|
24
|
+
case "link":
|
|
25
|
+
return `<a href="${escapeHtml(node.href)}">${escapeHtml(node.value)}</a>`;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function renderListItem(item: ListItemNode): string {
|
|
30
|
+
const content = item.children
|
|
31
|
+
.map((child) =>
|
|
32
|
+
child.type === "list"
|
|
33
|
+
? renderList(child)
|
|
34
|
+
: renderInline(child as InlineNode),
|
|
35
|
+
)
|
|
36
|
+
.join("");
|
|
37
|
+
return `<li>${content}</li>`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function renderList(node: ListNode): string {
|
|
41
|
+
const tag = node.ordered ? "ol" : "ul";
|
|
42
|
+
const items = node.items.map(renderListItem).join("");
|
|
43
|
+
return `<${tag}>${items}</${tag}>`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function renderHTML(doc: Document): string {
|
|
47
|
+
return doc.sections
|
|
48
|
+
.map((section) => {
|
|
49
|
+
// biome-ignore lint/suspicious/useIterableCallbackReturn: typed
|
|
50
|
+
const blocks = section.content.map((node) => {
|
|
51
|
+
switch (node.type) {
|
|
52
|
+
case "heading": {
|
|
53
|
+
const level = node.level ?? 2;
|
|
54
|
+
return `<h${level}>${escapeHtml(node.value)}</h${level}>`;
|
|
55
|
+
}
|
|
56
|
+
case "paragraph":
|
|
57
|
+
return `<p>${node.children.map(renderInline).join("")}</p>`;
|
|
58
|
+
case "list":
|
|
59
|
+
return renderList(node);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
return blocks.join("\n");
|
|
63
|
+
})
|
|
64
|
+
.join("\n");
|
|
65
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import type { PolicyInput } from "@openpolicy/core";
|
|
3
|
+
import { compilePolicy } from "./index";
|
|
4
|
+
|
|
5
|
+
const input: PolicyInput = {
|
|
6
|
+
type: "privacy",
|
|
7
|
+
effectiveDate: "2026-01-01",
|
|
8
|
+
company: {
|
|
9
|
+
name: "Acme Inc.",
|
|
10
|
+
legalName: "Acme Corporation",
|
|
11
|
+
address: "123 Main St, Springfield, USA",
|
|
12
|
+
contact: "privacy@acme.com",
|
|
13
|
+
},
|
|
14
|
+
dataCollected: { "Account Information": ["Name", "Email"] },
|
|
15
|
+
legalBasis: "Legitimate interests",
|
|
16
|
+
retention: { "Account data": "Until deletion" },
|
|
17
|
+
cookies: { essential: true, analytics: false, marketing: false },
|
|
18
|
+
thirdParties: [],
|
|
19
|
+
userRights: ["access"],
|
|
20
|
+
jurisdictions: ["us"],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
test("compilePolicy routes privacy input to markdown", async () => {
|
|
24
|
+
const results = await compilePolicy(input);
|
|
25
|
+
expect(results).toBeArray();
|
|
26
|
+
expect(results[0]?.format).toBe("markdown");
|
|
27
|
+
expect(results[0]?.content).toContain("Acme Inc.");
|
|
28
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CompileOptions,
|
|
3
|
+
OutputFormat,
|
|
4
|
+
PolicyInput,
|
|
5
|
+
} from "@openpolicy/core";
|
|
6
|
+
import { compile } from "@openpolicy/core";
|
|
7
|
+
|
|
8
|
+
export { renderHTML } from "./html";
|
|
9
|
+
export { renderMarkdown } from "./markdown";
|
|
10
|
+
export { renderPDF } from "./pdf";
|
|
11
|
+
|
|
12
|
+
function filenameFor(type: PolicyInput["type"], ext: string): string {
|
|
13
|
+
switch (type) {
|
|
14
|
+
case "privacy":
|
|
15
|
+
return `privacy-policy.${ext}`;
|
|
16
|
+
case "terms":
|
|
17
|
+
return `terms-of-service.${ext}`;
|
|
18
|
+
case "cookie":
|
|
19
|
+
return `cookie-policy.${ext}`;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function compilePolicy(
|
|
24
|
+
input: PolicyInput,
|
|
25
|
+
options?: CompileOptions,
|
|
26
|
+
): Promise<
|
|
27
|
+
{ format: OutputFormat; filename: string; content: string | Buffer }[]
|
|
28
|
+
> {
|
|
29
|
+
const doc = compile(input);
|
|
30
|
+
const formats = options?.formats ?? ["markdown"];
|
|
31
|
+
return Promise.all(
|
|
32
|
+
formats.map(async (format) => {
|
|
33
|
+
switch (format) {
|
|
34
|
+
case "markdown": {
|
|
35
|
+
const { renderMarkdown } = await import("./markdown");
|
|
36
|
+
return {
|
|
37
|
+
format,
|
|
38
|
+
filename: filenameFor(input.type, "md"),
|
|
39
|
+
content: renderMarkdown(doc),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
case "html": {
|
|
43
|
+
const { renderHTML } = await import("./html");
|
|
44
|
+
return {
|
|
45
|
+
format,
|
|
46
|
+
filename: filenameFor(input.type, "html"),
|
|
47
|
+
content: renderHTML(doc),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
case "pdf": {
|
|
51
|
+
const { renderPDF } = await import("./pdf");
|
|
52
|
+
return {
|
|
53
|
+
format,
|
|
54
|
+
filename: filenameFor(input.type, "pdf"),
|
|
55
|
+
content: await renderPDF(doc),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
default:
|
|
59
|
+
throw new Error(`Format not yet implemented: ${format}`);
|
|
60
|
+
}
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import type { Document } from "@openpolicy/core";
|
|
3
|
+
import { renderMarkdown } from "./markdown";
|
|
4
|
+
|
|
5
|
+
function doc(sections: Document["sections"]): Document {
|
|
6
|
+
return { type: "document", policyType: "privacy", sections };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
test("renders a heading node", () => {
|
|
10
|
+
const result = renderMarkdown(
|
|
11
|
+
doc([
|
|
12
|
+
{
|
|
13
|
+
type: "section",
|
|
14
|
+
id: "s1",
|
|
15
|
+
content: [{ type: "heading", value: "Introduction" }],
|
|
16
|
+
},
|
|
17
|
+
]),
|
|
18
|
+
);
|
|
19
|
+
expect(result).toBe("## Introduction");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("renders a paragraph with text", () => {
|
|
23
|
+
const result = renderMarkdown(
|
|
24
|
+
doc([
|
|
25
|
+
{
|
|
26
|
+
type: "section",
|
|
27
|
+
id: "s1",
|
|
28
|
+
content: [
|
|
29
|
+
{
|
|
30
|
+
type: "paragraph",
|
|
31
|
+
children: [{ type: "text", value: "Hello world" }],
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
]),
|
|
36
|
+
);
|
|
37
|
+
expect(result).toBe("Hello world");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("renders bold and link inline nodes", () => {
|
|
41
|
+
const result = renderMarkdown(
|
|
42
|
+
doc([
|
|
43
|
+
{
|
|
44
|
+
type: "section",
|
|
45
|
+
id: "s1",
|
|
46
|
+
content: [
|
|
47
|
+
{
|
|
48
|
+
type: "paragraph",
|
|
49
|
+
children: [
|
|
50
|
+
{ type: "bold", value: "Important" },
|
|
51
|
+
{ type: "text", value: ": see " },
|
|
52
|
+
{ type: "link", href: "https://example.com", value: "here" },
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
]),
|
|
58
|
+
);
|
|
59
|
+
expect(result).toBe("**Important**: see [here](https://example.com)");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("renders a list with items", () => {
|
|
63
|
+
const result = renderMarkdown(
|
|
64
|
+
doc([
|
|
65
|
+
{
|
|
66
|
+
type: "section",
|
|
67
|
+
id: "s1",
|
|
68
|
+
content: [
|
|
69
|
+
{
|
|
70
|
+
type: "list",
|
|
71
|
+
items: [
|
|
72
|
+
{
|
|
73
|
+
type: "listItem",
|
|
74
|
+
children: [{ type: "text", value: "Alpha" }],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
type: "listItem",
|
|
78
|
+
children: [{ type: "text", value: "Beta" }],
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
]),
|
|
85
|
+
);
|
|
86
|
+
expect(result).toBe("- Alpha\n- Beta");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("renders nested list with indented items", () => {
|
|
90
|
+
const result = renderMarkdown(
|
|
91
|
+
doc([
|
|
92
|
+
{
|
|
93
|
+
type: "section",
|
|
94
|
+
id: "s1",
|
|
95
|
+
content: [
|
|
96
|
+
{
|
|
97
|
+
type: "list",
|
|
98
|
+
items: [
|
|
99
|
+
{
|
|
100
|
+
type: "listItem",
|
|
101
|
+
children: [
|
|
102
|
+
{ type: "text", value: "Parent" },
|
|
103
|
+
{
|
|
104
|
+
type: "list",
|
|
105
|
+
items: [
|
|
106
|
+
{
|
|
107
|
+
type: "listItem",
|
|
108
|
+
children: [{ type: "text", value: "Child" }],
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
]),
|
|
119
|
+
);
|
|
120
|
+
expect(result).toBe("- Parent\n - Child");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("joins multiple sections with dividers", () => {
|
|
124
|
+
const result = renderMarkdown(
|
|
125
|
+
doc([
|
|
126
|
+
{
|
|
127
|
+
type: "section",
|
|
128
|
+
id: "s1",
|
|
129
|
+
content: [{ type: "heading", value: "One" }],
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
type: "section",
|
|
133
|
+
id: "s2",
|
|
134
|
+
content: [{ type: "heading", value: "Two" }],
|
|
135
|
+
},
|
|
136
|
+
]),
|
|
137
|
+
);
|
|
138
|
+
expect(result).toBe("## One\n\n---\n\n## Two");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("renders heading with level 3", () => {
|
|
142
|
+
const result = renderMarkdown(
|
|
143
|
+
doc([
|
|
144
|
+
{
|
|
145
|
+
type: "section",
|
|
146
|
+
id: "s1",
|
|
147
|
+
content: [{ type: "heading", level: 3, value: "Sub-section" }],
|
|
148
|
+
},
|
|
149
|
+
]),
|
|
150
|
+
);
|
|
151
|
+
expect(result).toBe("### Sub-section");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("renders heading with level 1", () => {
|
|
155
|
+
const result = renderMarkdown(
|
|
156
|
+
doc([
|
|
157
|
+
{
|
|
158
|
+
type: "section",
|
|
159
|
+
id: "s1",
|
|
160
|
+
content: [{ type: "heading", level: 1, value: "Title" }],
|
|
161
|
+
},
|
|
162
|
+
]),
|
|
163
|
+
);
|
|
164
|
+
expect(result).toBe("# Title");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("renders ordered list with numbered items", () => {
|
|
168
|
+
const result = renderMarkdown(
|
|
169
|
+
doc([
|
|
170
|
+
{
|
|
171
|
+
type: "section",
|
|
172
|
+
id: "s1",
|
|
173
|
+
content: [
|
|
174
|
+
{
|
|
175
|
+
type: "list",
|
|
176
|
+
ordered: true,
|
|
177
|
+
items: [
|
|
178
|
+
{
|
|
179
|
+
type: "listItem",
|
|
180
|
+
children: [{ type: "text", value: "First" }],
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
type: "listItem",
|
|
184
|
+
children: [{ type: "text", value: "Second" }],
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
},
|
|
190
|
+
]),
|
|
191
|
+
);
|
|
192
|
+
expect(result).toBe("1. First\n2. Second");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("renders italic inline node", () => {
|
|
196
|
+
const result = renderMarkdown(
|
|
197
|
+
doc([
|
|
198
|
+
{
|
|
199
|
+
type: "section",
|
|
200
|
+
id: "s1",
|
|
201
|
+
content: [
|
|
202
|
+
{
|
|
203
|
+
type: "paragraph",
|
|
204
|
+
children: [{ type: "italic", value: "emphasis" }],
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
},
|
|
208
|
+
]),
|
|
209
|
+
);
|
|
210
|
+
expect(result).toBe("_emphasis_");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("joins multiple content nodes within a section with blank lines", () => {
|
|
214
|
+
const result = renderMarkdown(
|
|
215
|
+
doc([
|
|
216
|
+
{
|
|
217
|
+
type: "section",
|
|
218
|
+
id: "s1",
|
|
219
|
+
content: [
|
|
220
|
+
{ type: "heading", value: "Title" },
|
|
221
|
+
{
|
|
222
|
+
type: "paragraph",
|
|
223
|
+
children: [{ type: "text", value: "Body text." }],
|
|
224
|
+
},
|
|
225
|
+
],
|
|
226
|
+
},
|
|
227
|
+
]),
|
|
228
|
+
);
|
|
229
|
+
expect(result).toBe("## Title\n\nBody text.");
|
|
230
|
+
});
|
package/src/markdown.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Document,
|
|
3
|
+
InlineNode,
|
|
4
|
+
ListItemNode,
|
|
5
|
+
ListNode,
|
|
6
|
+
} from "@openpolicy/core";
|
|
7
|
+
|
|
8
|
+
function renderInline(node: InlineNode): string {
|
|
9
|
+
switch (node.type) {
|
|
10
|
+
case "text":
|
|
11
|
+
return node.value;
|
|
12
|
+
case "bold":
|
|
13
|
+
return `**${node.value}**`;
|
|
14
|
+
case "italic":
|
|
15
|
+
return `_${node.value}_`;
|
|
16
|
+
case "link":
|
|
17
|
+
return `[${node.value}](${node.href})`;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function renderListItem(
|
|
22
|
+
item: ListItemNode,
|
|
23
|
+
indent = "",
|
|
24
|
+
ordered = false,
|
|
25
|
+
index = 0,
|
|
26
|
+
): string {
|
|
27
|
+
const parts: string[] = [];
|
|
28
|
+
let nestedList: ListNode | null = null;
|
|
29
|
+
for (const child of item.children) {
|
|
30
|
+
if (child.type === "list") {
|
|
31
|
+
nestedList = child;
|
|
32
|
+
} else {
|
|
33
|
+
parts.push(renderInline(child));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const bullet = ordered ? `${index + 1}.` : "-";
|
|
37
|
+
const line = `${indent}${bullet} ${parts.join("")}`;
|
|
38
|
+
if (nestedList) {
|
|
39
|
+
const nested = nestedList.items
|
|
40
|
+
.map((i, idx) =>
|
|
41
|
+
renderListItem(i, `${indent} `, nestedList!.ordered, idx),
|
|
42
|
+
)
|
|
43
|
+
.join("\n");
|
|
44
|
+
return `${line}\n${nested}`;
|
|
45
|
+
}
|
|
46
|
+
return line;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function renderMarkdown(doc: Document): string {
|
|
50
|
+
return doc.sections
|
|
51
|
+
.map((section) => {
|
|
52
|
+
// biome-ignore lint/suspicious/useIterableCallbackReturn: typed
|
|
53
|
+
const blocks = section.content.map((node) => {
|
|
54
|
+
switch (node.type) {
|
|
55
|
+
case "heading": {
|
|
56
|
+
const hashes = "#".repeat(node.level ?? 2);
|
|
57
|
+
return `${hashes} ${node.value}`;
|
|
58
|
+
}
|
|
59
|
+
case "paragraph":
|
|
60
|
+
return node.children.map(renderInline).join("");
|
|
61
|
+
case "list":
|
|
62
|
+
return node.items
|
|
63
|
+
.map((item, idx) => renderListItem(item, "", node.ordered, idx))
|
|
64
|
+
.join("\n");
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
return blocks.join("\n\n");
|
|
68
|
+
})
|
|
69
|
+
.join("\n\n---\n\n");
|
|
70
|
+
}
|
package/src/pdf.test.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { compile } from "@openpolicy/core";
|
|
3
|
+
import { renderPDF } from "./pdf";
|
|
4
|
+
|
|
5
|
+
const input = {
|
|
6
|
+
type: "privacy" as const,
|
|
7
|
+
effectiveDate: "2026-01-01",
|
|
8
|
+
company: {
|
|
9
|
+
name: "Acme Inc.",
|
|
10
|
+
legalName: "Acme Corporation",
|
|
11
|
+
address: "123 Main St",
|
|
12
|
+
contact: "privacy@acme.com",
|
|
13
|
+
},
|
|
14
|
+
dataCollected: { "Account Information": ["Name", "Email"] },
|
|
15
|
+
legalBasis: "Legitimate interests",
|
|
16
|
+
retention: { "Account data": "Until deletion" },
|
|
17
|
+
cookies: { essential: true, analytics: false, marketing: false },
|
|
18
|
+
thirdParties: [],
|
|
19
|
+
userRights: ["access" as const],
|
|
20
|
+
jurisdictions: ["us" as const],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
test("renderPDF returns a Buffer", async () => {
|
|
24
|
+
const doc = compile(input);
|
|
25
|
+
const result = await renderPDF(doc);
|
|
26
|
+
expect(result).toBeInstanceOf(Buffer);
|
|
27
|
+
expect(result.length).toBeGreaterThan(100);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("renderPDF output begins with PDF magic bytes", async () => {
|
|
31
|
+
const doc = compile(input);
|
|
32
|
+
const result = await renderPDF(doc);
|
|
33
|
+
expect(result.slice(0, 5).toString("ascii")).toBe("%PDF-");
|
|
34
|
+
});
|
package/src/pdf.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Document,
|
|
3
|
+
DocumentSection,
|
|
4
|
+
InlineNode,
|
|
5
|
+
ListItemNode,
|
|
6
|
+
ListNode,
|
|
7
|
+
} from "@openpolicy/core";
|
|
8
|
+
import PDFDocument from "pdfkit";
|
|
9
|
+
|
|
10
|
+
const FONT_REGULAR = "Helvetica";
|
|
11
|
+
const FONT_BOLD = "Helvetica-Bold";
|
|
12
|
+
const FONT_ITALIC = "Helvetica-Oblique";
|
|
13
|
+
const SIZE_HEADING_BASE = 16;
|
|
14
|
+
const SIZE_BODY = 11;
|
|
15
|
+
const COLOR_BODY = "#374151";
|
|
16
|
+
const COLOR_HEADING = "#111827";
|
|
17
|
+
const COLOR_LINK = "#2563eb";
|
|
18
|
+
|
|
19
|
+
function headingSize(level: number): number {
|
|
20
|
+
// H1=16, H2=14, H3=13, H4=12, H5=11, H6=10
|
|
21
|
+
return Math.max(10, SIZE_HEADING_BASE - (level - 1) * 1.5);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function renderInlineNodes(
|
|
25
|
+
doc: InstanceType<typeof PDFDocument>,
|
|
26
|
+
nodes: InlineNode[],
|
|
27
|
+
): void {
|
|
28
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
29
|
+
const node = nodes[i];
|
|
30
|
+
const continued = i < nodes.length - 1;
|
|
31
|
+
switch (node!.type) {
|
|
32
|
+
case "text":
|
|
33
|
+
doc
|
|
34
|
+
.font(FONT_REGULAR)
|
|
35
|
+
.fillColor(COLOR_BODY)
|
|
36
|
+
.text(node!.value, { continued });
|
|
37
|
+
break;
|
|
38
|
+
case "bold":
|
|
39
|
+
doc
|
|
40
|
+
.font(FONT_BOLD)
|
|
41
|
+
.fillColor(COLOR_HEADING)
|
|
42
|
+
.text(node!.value, { continued });
|
|
43
|
+
break;
|
|
44
|
+
case "italic":
|
|
45
|
+
doc
|
|
46
|
+
.font(FONT_ITALIC)
|
|
47
|
+
.fillColor(COLOR_BODY)
|
|
48
|
+
.text(node!.value, { continued });
|
|
49
|
+
break;
|
|
50
|
+
case "link":
|
|
51
|
+
doc
|
|
52
|
+
.font(FONT_REGULAR)
|
|
53
|
+
.fillColor(COLOR_LINK)
|
|
54
|
+
.text(node!.value, { continued, link: node!.href, underline: true });
|
|
55
|
+
doc.fillColor(COLOR_BODY);
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function renderListItem(
|
|
62
|
+
doc: InstanceType<typeof PDFDocument>,
|
|
63
|
+
item: ListItemNode,
|
|
64
|
+
depth: number,
|
|
65
|
+
ordered: boolean,
|
|
66
|
+
index: number,
|
|
67
|
+
): void {
|
|
68
|
+
const indent = doc.page.margins.left + 10 + depth * 15;
|
|
69
|
+
const bullet = ordered ? `${index + 1}.` : depth === 0 ? "•" : "◦";
|
|
70
|
+
|
|
71
|
+
const inlineNodes = item.children.filter(
|
|
72
|
+
(c): c is InlineNode => c.type !== "list",
|
|
73
|
+
);
|
|
74
|
+
const nested =
|
|
75
|
+
item.children.find((c): c is ListNode => c.type === "list") ?? null;
|
|
76
|
+
|
|
77
|
+
doc
|
|
78
|
+
.font(FONT_REGULAR)
|
|
79
|
+
.fontSize(SIZE_BODY)
|
|
80
|
+
.fillColor(COLOR_BODY)
|
|
81
|
+
.text(`${bullet} `, { continued: true, indent });
|
|
82
|
+
|
|
83
|
+
if (inlineNodes.length > 0) {
|
|
84
|
+
renderInlineNodes(doc, inlineNodes);
|
|
85
|
+
} else {
|
|
86
|
+
doc.text("");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (nested) {
|
|
90
|
+
for (let i = 0; i < nested.items.length; i++) {
|
|
91
|
+
renderListItem(
|
|
92
|
+
doc,
|
|
93
|
+
nested.items[i]!,
|
|
94
|
+
depth + 1,
|
|
95
|
+
nested.ordered ?? false,
|
|
96
|
+
i,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function renderSection(
|
|
103
|
+
doc: InstanceType<typeof PDFDocument>,
|
|
104
|
+
section: DocumentSection,
|
|
105
|
+
isFirst: boolean,
|
|
106
|
+
): void {
|
|
107
|
+
if (!isFirst) {
|
|
108
|
+
doc.moveDown(0.5);
|
|
109
|
+
doc
|
|
110
|
+
.moveTo(doc.page.margins.left, doc.y)
|
|
111
|
+
.lineTo(doc.page.width - doc.page.margins.right, doc.y)
|
|
112
|
+
.strokeColor("#e5e7eb")
|
|
113
|
+
.lineWidth(0.5)
|
|
114
|
+
.stroke();
|
|
115
|
+
doc.moveDown(0.5);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const node of section.content) {
|
|
119
|
+
switch (node.type) {
|
|
120
|
+
case "heading":
|
|
121
|
+
doc
|
|
122
|
+
.font(FONT_BOLD)
|
|
123
|
+
.fontSize(headingSize(node.level ?? 2))
|
|
124
|
+
.fillColor(COLOR_HEADING)
|
|
125
|
+
.text(node.value)
|
|
126
|
+
.moveDown(0.3);
|
|
127
|
+
break;
|
|
128
|
+
case "paragraph":
|
|
129
|
+
doc.font(FONT_REGULAR).fontSize(SIZE_BODY);
|
|
130
|
+
renderInlineNodes(doc, node.children);
|
|
131
|
+
doc.moveDown(0.3);
|
|
132
|
+
break;
|
|
133
|
+
case "list":
|
|
134
|
+
doc.fontSize(SIZE_BODY);
|
|
135
|
+
for (let i = 0; i < node.items.length; i++) {
|
|
136
|
+
renderListItem(doc, node.items[i]!, 0, node.ordered ?? false, i);
|
|
137
|
+
}
|
|
138
|
+
doc.moveDown(0.3);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function renderPDF(document: Document): Promise<Buffer> {
|
|
145
|
+
return new Promise<Buffer>((resolve, reject) => {
|
|
146
|
+
const pdf = new PDFDocument({ margin: 50, size: "A4" });
|
|
147
|
+
const chunks: Buffer[] = [];
|
|
148
|
+
pdf.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
149
|
+
pdf.on("end", () => resolve(Buffer.concat(chunks)));
|
|
150
|
+
pdf.on("error", reject);
|
|
151
|
+
|
|
152
|
+
for (let i = 0; i < document.sections.length; i++) {
|
|
153
|
+
renderSection(pdf, document.sections[i]!, i === 0);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
pdf.end();
|
|
157
|
+
});
|
|
158
|
+
}
|