framer-cms-markdown 0.0.1
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/.eslintrc.json +7 -0
- package/LICENSE +201 -0
- package/README.md +49 -0
- package/dist/src/__tests__/filename.spec.js +15 -0
- package/dist/src/__tests__/frontmatter.spec.js +15 -0
- package/dist/src/__tests__/markdown.spec.js +9 -0
- package/dist/src/cli.js +62 -0
- package/dist/src/export.js +83 -0
- package/dist/src/filename.js +42 -0
- package/dist/src/framer-client.js +106 -0
- package/dist/src/framer-sync/config.js +50 -0
- package/dist/src/framer-sync/filename.js +41 -0
- package/dist/src/framer-sync/format.js +9 -0
- package/dist/src/framer-sync/framer-client.js +180 -0
- package/dist/src/framer-sync/frontmatter.js +49 -0
- package/dist/src/framer-sync/markdown.js +70 -0
- package/dist/src/framer-to-markdown/__tests__/export.spec.js +64 -0
- package/dist/src/framer-to-markdown/__tests__/faq.spec.js +23 -0
- package/dist/src/framer-to-markdown/__tests__/filename.spec.js +15 -0
- package/dist/src/framer-to-markdown/__tests__/framer-ref.spec.js +12 -0
- package/dist/src/framer-to-markdown/__tests__/frontmatter.spec.js +15 -0
- package/dist/src/framer-to-markdown/__tests__/markdown.spec.js +20 -0
- package/dist/src/framer-to-markdown/cli.js +5 -0
- package/dist/src/framer-to-markdown/config.js +1 -0
- package/dist/src/framer-to-markdown/export.js +138 -0
- package/dist/src/framer-to-markdown/faq.js +44 -0
- package/dist/src/framer-to-markdown/filename.js +1 -0
- package/dist/src/framer-to-markdown/format.js +1 -0
- package/dist/src/framer-to-markdown/framer-client.js +1 -0
- package/dist/src/framer-to-markdown/framer-ref.js +28 -0
- package/dist/src/framer-to-markdown/frontmatter.js +1 -0
- package/dist/src/framer-to-markdown/index.js +49 -0
- package/dist/src/framer-to-markdown/markdown.js +1 -0
- package/dist/src/frontmatter.js +39 -0
- package/dist/src/index.js +22 -0
- package/dist/src/libs/framer/config.js +50 -0
- package/dist/src/libs/framer/filename.js +41 -0
- package/dist/src/libs/framer/format.js +9 -0
- package/dist/src/libs/framer/framer-client.js +180 -0
- package/dist/src/libs/framer/frontmatter.js +49 -0
- package/dist/src/libs/framer/markdown.js +70 -0
- package/dist/src/markdown-to-framer/__tests__/import.spec.js +69 -0
- package/dist/src/markdown-to-framer/cli.js +5 -0
- package/dist/src/markdown-to-framer/import.js +142 -0
- package/dist/src/markdown-to-framer/index.js +51 -0
- package/dist/src/markdown.js +21 -0
- package/dist/test/filename.test.js +19 -0
- package/dist/test/frontmatter.test.js +19 -0
- package/dist/test/markdown.test.js +13 -0
- package/package.json +63 -0
- package/src/framer-to-markdown/__tests__/export.spec.ts +85 -0
- package/src/framer-to-markdown/__tests__/filename.spec.ts +21 -0
- package/src/framer-to-markdown/__tests__/frontmatter.spec.ts +18 -0
- package/src/framer-to-markdown/__tests__/markdown.spec.ts +26 -0
- package/src/framer-to-markdown/cli.ts +8 -0
- package/src/framer-to-markdown/config.ts +4 -0
- package/src/framer-to-markdown/export.ts +221 -0
- package/src/framer-to-markdown/filename.ts +5 -0
- package/src/framer-to-markdown/format.ts +1 -0
- package/src/framer-to-markdown/framer-client.ts +6 -0
- package/src/framer-to-markdown/frontmatter.ts +4 -0
- package/src/framer-to-markdown/index.ts +63 -0
- package/src/framer-to-markdown/markdown.ts +5 -0
- package/src/libs/framer/config.ts +79 -0
- package/src/libs/framer/filename.ts +59 -0
- package/src/libs/framer/format.ts +12 -0
- package/src/libs/framer/framer-client.ts +267 -0
- package/src/libs/framer/frontmatter.ts +69 -0
- package/src/libs/framer/markdown.ts +84 -0
- package/src/markdown-to-framer/__tests__/import.spec.ts +89 -0
- package/src/markdown-to-framer/__tests__/json-ld-faq.spec.ts +116 -0
- package/src/markdown-to-framer/cli.ts +6 -0
- package/src/markdown-to-framer/import.ts +251 -0
- package/src/markdown-to-framer/index.ts +83 -0
- package/src/markdown-to-framer/json-ld-faq.ts +146 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export function slugify(input) {
|
|
2
|
+
const normalized = input
|
|
3
|
+
.normalize("NFKD")
|
|
4
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
5
|
+
.toLowerCase()
|
|
6
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
7
|
+
.replace(/^-+|-+$/g, "")
|
|
8
|
+
.replace(/-{2,}/g, "-");
|
|
9
|
+
return normalized.length > 0 ? normalized : "item";
|
|
10
|
+
}
|
|
11
|
+
export function uniqueName(baseName, existingNames) {
|
|
12
|
+
if (!existingNames.has(baseName)) {
|
|
13
|
+
return baseName;
|
|
14
|
+
}
|
|
15
|
+
let suffix = 2;
|
|
16
|
+
while (existingNames.has(`${baseName}-${suffix}`)) {
|
|
17
|
+
suffix += 1;
|
|
18
|
+
}
|
|
19
|
+
return `${baseName}-${suffix}`;
|
|
20
|
+
}
|
|
21
|
+
export function resolveMarkdownFilename(item, existingNames = new Set()) {
|
|
22
|
+
const candidates = [
|
|
23
|
+
item.slug,
|
|
24
|
+
item.title,
|
|
25
|
+
item.id != null ? String(item.id) : null,
|
|
26
|
+
];
|
|
27
|
+
for (const candidate of candidates) {
|
|
28
|
+
if (!candidate) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const name = slugify(candidate);
|
|
32
|
+
if (name) {
|
|
33
|
+
const unique = uniqueName(name, existingNames);
|
|
34
|
+
existingNames.add(unique);
|
|
35
|
+
return `${unique}.md`;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const fallback = uniqueName("item", existingNames);
|
|
39
|
+
existingNames.add(fallback);
|
|
40
|
+
return `${fallback}.md`;
|
|
41
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import * as markdownPlugin from "prettier/plugins/markdown";
|
|
2
|
+
import * as prettier from "prettier/standalone";
|
|
3
|
+
export async function formatMarkdownDocument(markdown) {
|
|
4
|
+
return prettier.format(markdown, {
|
|
5
|
+
parser: "markdown",
|
|
6
|
+
plugins: [markdownPlugin],
|
|
7
|
+
proseWrap: "preserve",
|
|
8
|
+
});
|
|
9
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { connect, } from "framer-api";
|
|
2
|
+
function extractAssetUrl(value) {
|
|
3
|
+
if (!value || typeof value !== "object") {
|
|
4
|
+
return undefined;
|
|
5
|
+
}
|
|
6
|
+
const record = value;
|
|
7
|
+
return typeof record.url === "string" ? record.url : undefined;
|
|
8
|
+
}
|
|
9
|
+
function extractIdOrString(value) {
|
|
10
|
+
if (typeof value === "string") {
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
if (!value || typeof value !== "object") {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
const record = value;
|
|
17
|
+
if (typeof record.id === "string") {
|
|
18
|
+
return record.id;
|
|
19
|
+
}
|
|
20
|
+
if (typeof record.name === "string") {
|
|
21
|
+
return record.name;
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
function normalizeFieldEntry(entry) {
|
|
26
|
+
switch (entry.type) {
|
|
27
|
+
case "boolean":
|
|
28
|
+
case "number":
|
|
29
|
+
case "string":
|
|
30
|
+
case "enum":
|
|
31
|
+
case "link":
|
|
32
|
+
return entry.value;
|
|
33
|
+
case "date":
|
|
34
|
+
return entry.value ?? undefined;
|
|
35
|
+
case "formattedText":
|
|
36
|
+
return typeof entry.value === "string" ? entry.value : undefined;
|
|
37
|
+
case "image":
|
|
38
|
+
case "file":
|
|
39
|
+
return extractAssetUrl(entry.value);
|
|
40
|
+
case "color":
|
|
41
|
+
return extractIdOrString(entry.value);
|
|
42
|
+
case "collectionReference":
|
|
43
|
+
return entry.value ?? undefined;
|
|
44
|
+
case "multiCollectionReference":
|
|
45
|
+
return Array.isArray(entry.value) ? entry.value : undefined;
|
|
46
|
+
default:
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export function createFieldDataEntryInput(field, value, options = {}) {
|
|
51
|
+
switch (field.type) {
|
|
52
|
+
case "boolean":
|
|
53
|
+
return typeof value === "boolean"
|
|
54
|
+
? { type: field.type, value }
|
|
55
|
+
: undefined;
|
|
56
|
+
case "number":
|
|
57
|
+
return typeof value === "number" ? { type: field.type, value } : undefined;
|
|
58
|
+
case "string":
|
|
59
|
+
return typeof value === "string" ? { type: field.type, value } : undefined;
|
|
60
|
+
case "formattedText":
|
|
61
|
+
return typeof value === "string"
|
|
62
|
+
? {
|
|
63
|
+
type: field.type,
|
|
64
|
+
value,
|
|
65
|
+
contentType: options.formattedTextContentType ?? "html",
|
|
66
|
+
}
|
|
67
|
+
: undefined;
|
|
68
|
+
case "enum":
|
|
69
|
+
return typeof value === "string" ? { type: field.type, value } : undefined;
|
|
70
|
+
case "link":
|
|
71
|
+
return typeof value === "string" ? { type: field.type, value } : undefined;
|
|
72
|
+
case "date":
|
|
73
|
+
return typeof value === "string" || typeof value === "number"
|
|
74
|
+
? { type: field.type, value: value }
|
|
75
|
+
: undefined;
|
|
76
|
+
case "collectionReference":
|
|
77
|
+
return typeof value === "string" || value === null
|
|
78
|
+
? { type: field.type, value: value }
|
|
79
|
+
: undefined;
|
|
80
|
+
case "multiCollectionReference":
|
|
81
|
+
return Array.isArray(value) &&
|
|
82
|
+
value.every((entry) => typeof entry === "string")
|
|
83
|
+
? { type: field.type, value: value }
|
|
84
|
+
: undefined;
|
|
85
|
+
case "color":
|
|
86
|
+
case "file":
|
|
87
|
+
case "image":
|
|
88
|
+
return typeof value === "string" || value === null
|
|
89
|
+
? { type: field.type, value: value }
|
|
90
|
+
: undefined;
|
|
91
|
+
case "array":
|
|
92
|
+
case "divider":
|
|
93
|
+
case "unsupported":
|
|
94
|
+
return undefined;
|
|
95
|
+
default:
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export class FramerClient {
|
|
100
|
+
constructor(options) {
|
|
101
|
+
this.projectRef = options.projectRef;
|
|
102
|
+
this.token = options.token;
|
|
103
|
+
}
|
|
104
|
+
async connect() {
|
|
105
|
+
if (!this.framer) {
|
|
106
|
+
this.framer = await connect(this.projectRef, this.token);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async disconnect() {
|
|
110
|
+
if (this.framer) {
|
|
111
|
+
await this.framer.disconnect();
|
|
112
|
+
this.framer = undefined;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async getCollections() {
|
|
116
|
+
return (await this.getFramer()).getCollections();
|
|
117
|
+
}
|
|
118
|
+
async resolveCollection(collectionRef) {
|
|
119
|
+
const collections = await this.getCollections();
|
|
120
|
+
const normalizedRef = collectionRef.trim().toLowerCase();
|
|
121
|
+
const match = collections.find((collection) => {
|
|
122
|
+
return (collection.id.toLowerCase() === normalizedRef ||
|
|
123
|
+
collection.name.toLowerCase() === normalizedRef);
|
|
124
|
+
});
|
|
125
|
+
if (!match) {
|
|
126
|
+
const available = collections
|
|
127
|
+
.map((collection) => `${collection.name} (${collection.id})`)
|
|
128
|
+
.join(", ");
|
|
129
|
+
throw new Error(`Collection "${collectionRef}" was not found. Available collections: ${available || "none"}.`);
|
|
130
|
+
}
|
|
131
|
+
return match;
|
|
132
|
+
}
|
|
133
|
+
async getCollectionData(collectionRef) {
|
|
134
|
+
const collection = await this.resolveCollection(collectionRef);
|
|
135
|
+
const fields = await collection.getFields();
|
|
136
|
+
const items = await collection.getItems();
|
|
137
|
+
return { collection, fields, items };
|
|
138
|
+
}
|
|
139
|
+
async getCollectionExportData(collectionRef) {
|
|
140
|
+
const { collection, fields, items } = await this.getCollectionData(collectionRef);
|
|
141
|
+
return {
|
|
142
|
+
collection,
|
|
143
|
+
fields,
|
|
144
|
+
items: items.map((item) => this.toExportItem(item, fields)),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
async upsertCollectionItems(collection, items) {
|
|
148
|
+
await collection.addItems(items);
|
|
149
|
+
}
|
|
150
|
+
async getFramer() {
|
|
151
|
+
await this.connect();
|
|
152
|
+
if (!this.framer) {
|
|
153
|
+
throw new Error("Failed to connect to Framer");
|
|
154
|
+
}
|
|
155
|
+
return this.framer;
|
|
156
|
+
}
|
|
157
|
+
toExportItem(item, fields) {
|
|
158
|
+
const fieldData = {};
|
|
159
|
+
for (const field of fields) {
|
|
160
|
+
if (field.type === "divider") {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const entry = item.fieldData[field.id];
|
|
164
|
+
if (!entry) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const value = normalizeFieldEntry(entry);
|
|
168
|
+
if (value === undefined) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
fieldData[field.name] = value;
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
id: String(item.id),
|
|
175
|
+
slug: item.slug,
|
|
176
|
+
draft: item.draft,
|
|
177
|
+
fieldData,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import yaml from "yaml";
|
|
2
|
+
function normalizeYamlValue(value) {
|
|
3
|
+
if (value instanceof Date) {
|
|
4
|
+
return value.toISOString();
|
|
5
|
+
}
|
|
6
|
+
if (Array.isArray(value)) {
|
|
7
|
+
return value.map(normalizeYamlValue);
|
|
8
|
+
}
|
|
9
|
+
if (value && typeof value === "object") {
|
|
10
|
+
const output = {};
|
|
11
|
+
for (const [key, nestedValue] of Object.entries(value)) {
|
|
12
|
+
if (nestedValue !== undefined) {
|
|
13
|
+
output[key] = normalizeYamlValue(nestedValue);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return output;
|
|
17
|
+
}
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
export function toFrontmatter(data) {
|
|
21
|
+
const filtered = {};
|
|
22
|
+
for (const [key, value] of Object.entries(data)) {
|
|
23
|
+
if (value === undefined) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
filtered[key] = normalizeYamlValue(value);
|
|
27
|
+
}
|
|
28
|
+
const rendered = yaml.stringify(filtered, {
|
|
29
|
+
lineWidth: 0,
|
|
30
|
+
singleQuote: true,
|
|
31
|
+
});
|
|
32
|
+
return `---\n${rendered.trimEnd()}\n---\n\n`;
|
|
33
|
+
}
|
|
34
|
+
export function parseFrontmatterDocument(content) {
|
|
35
|
+
const normalized = content.replace(/\r\n/g, "\n");
|
|
36
|
+
const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
37
|
+
if (!match) {
|
|
38
|
+
return { data: {}, body: normalized };
|
|
39
|
+
}
|
|
40
|
+
const [, rawFrontmatter, rawBody] = match;
|
|
41
|
+
const parsed = yaml.parse(rawFrontmatter);
|
|
42
|
+
const data = parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
43
|
+
? parsed
|
|
44
|
+
: {};
|
|
45
|
+
return {
|
|
46
|
+
data,
|
|
47
|
+
body: rawBody.replace(/^\n+/, ""),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import rehypeParse from "rehype-parse";
|
|
2
|
+
import rehypeRemark from "rehype-remark";
|
|
3
|
+
import rehypeStringify from "rehype-stringify";
|
|
4
|
+
import remarkGfm from "remark-gfm";
|
|
5
|
+
import remarkParse from "remark-parse";
|
|
6
|
+
import remarkRehype from "remark-rehype";
|
|
7
|
+
import remarkStringify from "remark-stringify";
|
|
8
|
+
import { unified } from "unified";
|
|
9
|
+
const htmlToMarkdownProcessor = unified()
|
|
10
|
+
.use(rehypeParse, { fragment: true })
|
|
11
|
+
.use(rehypeRemark)
|
|
12
|
+
.use(remarkGfm, {
|
|
13
|
+
tablePipeAlign: true,
|
|
14
|
+
tableCellPadding: true,
|
|
15
|
+
})
|
|
16
|
+
.use(remarkStringify, {
|
|
17
|
+
bullet: "-",
|
|
18
|
+
emphasis: "_",
|
|
19
|
+
fences: true,
|
|
20
|
+
setext: false,
|
|
21
|
+
});
|
|
22
|
+
const markdownToHtmlProcessor = unified()
|
|
23
|
+
.use(remarkParse)
|
|
24
|
+
.use(remarkGfm)
|
|
25
|
+
.use(remarkRehype)
|
|
26
|
+
.use(rehypeStringify, { closeSelfClosing: true });
|
|
27
|
+
function normalizeFragment(markdown) {
|
|
28
|
+
return markdown.trim().replace(/\n{3,}/g, "\n\n");
|
|
29
|
+
}
|
|
30
|
+
function stripMarkdownComments(markdown) {
|
|
31
|
+
return markdown.replace(/<!--[\s\S]*?-->\s*/g, "").trim();
|
|
32
|
+
}
|
|
33
|
+
export async function htmlToMarkdown(html) {
|
|
34
|
+
const input = html.trim();
|
|
35
|
+
if (!input) {
|
|
36
|
+
return "";
|
|
37
|
+
}
|
|
38
|
+
const file = await htmlToMarkdownProcessor.process(input);
|
|
39
|
+
const markdown = normalizeFragment(String(file));
|
|
40
|
+
return markdown.length > 0 ? `${markdown}\n` : "";
|
|
41
|
+
}
|
|
42
|
+
export async function markdownToHtml(markdown) {
|
|
43
|
+
const input = stripMarkdownComments(markdown);
|
|
44
|
+
if (!input) {
|
|
45
|
+
return "";
|
|
46
|
+
}
|
|
47
|
+
const file = await markdownToHtmlProcessor.process(input);
|
|
48
|
+
return String(file).trim();
|
|
49
|
+
}
|
|
50
|
+
export function splitMarkdownIntoSections(markdown) {
|
|
51
|
+
const normalized = markdown.replace(/\r\n/g, "\n").trim();
|
|
52
|
+
if (!normalized) {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
const lines = normalized.split("\n");
|
|
56
|
+
const sections = [];
|
|
57
|
+
let current = [];
|
|
58
|
+
for (const line of lines) {
|
|
59
|
+
if (/^##\s+/.test(line) && current.length > 0) {
|
|
60
|
+
sections.push(current.join("\n").trim());
|
|
61
|
+
current = [line];
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
current.push(line);
|
|
65
|
+
}
|
|
66
|
+
if (current.length > 0) {
|
|
67
|
+
sections.push(current.join("\n").trim());
|
|
68
|
+
}
|
|
69
|
+
return sections.filter((section) => section.length > 0);
|
|
70
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "@jest/globals";
|
|
5
|
+
import { exportItemsToMarkdown } from "../export";
|
|
6
|
+
const tempDirs = [];
|
|
7
|
+
afterEach(async () => {
|
|
8
|
+
await Promise.all(tempDirs
|
|
9
|
+
.splice(0)
|
|
10
|
+
.map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
|
11
|
+
});
|
|
12
|
+
describe("export", () => {
|
|
13
|
+
it("writes the configured markdown body and curated frontmatter", async () => {
|
|
14
|
+
const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), "framer-cms-markdown-"));
|
|
15
|
+
tempDirs.push(outputDir);
|
|
16
|
+
const [writtenFile] = await exportItemsToMarkdown([
|
|
17
|
+
{
|
|
18
|
+
id: "1",
|
|
19
|
+
slug: "example-post",
|
|
20
|
+
draft: false,
|
|
21
|
+
fieldData: {
|
|
22
|
+
Title: "Example Post",
|
|
23
|
+
Excerpt: "Short summary",
|
|
24
|
+
"Section 02 HTML": '<h2 dir="auto">Second</h2><p dir="auto">Hello world.</p>',
|
|
25
|
+
"Section 01 HTML": '<!-- internal metadata --><h1 dir="auto">Example Post</h1><p dir="auto">Intro text.</p>',
|
|
26
|
+
"Workflow Archetype": "continuous_enrichment",
|
|
27
|
+
"Primary Team": "Product",
|
|
28
|
+
"Company Type": "SaaS",
|
|
29
|
+
"Strategic Category": "Product Intelligence",
|
|
30
|
+
"Business Outcome": "Improve roadmap prioritization",
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
], {
|
|
34
|
+
outputDir,
|
|
35
|
+
bodyFieldName: "sections",
|
|
36
|
+
frontmatterFields: [
|
|
37
|
+
"Title",
|
|
38
|
+
"Excerpt",
|
|
39
|
+
"Workflow Archetype",
|
|
40
|
+
"Primary Team",
|
|
41
|
+
"Company Type",
|
|
42
|
+
"Strategic Category",
|
|
43
|
+
"Business Outcome",
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
const contents = await fs.readFile(writtenFile, "utf8");
|
|
47
|
+
expect(contents).toContain("slug: example-post");
|
|
48
|
+
expect(contents).toContain("Title: Example Post");
|
|
49
|
+
expect(contents).toContain("Excerpt: Short summary");
|
|
50
|
+
expect(contents).toContain("Workflow Archetype: continuous_enrichment");
|
|
51
|
+
expect(contents).toContain("Primary Team: Product");
|
|
52
|
+
expect(contents).toContain("Company Type: SaaS");
|
|
53
|
+
expect(contents).toContain("Strategic Category: Product Intelligence");
|
|
54
|
+
expect(contents).toContain("Business Outcome: Improve roadmap prioritization");
|
|
55
|
+
expect(contents).not.toContain("draft:");
|
|
56
|
+
expect(contents).not.toContain("internal metadata");
|
|
57
|
+
expect(contents).toContain("# Example Post");
|
|
58
|
+
expect(contents).toContain("Intro text.");
|
|
59
|
+
expect(contents).toContain("## Second");
|
|
60
|
+
expect(contents).toContain("Hello world.");
|
|
61
|
+
expect(contents.indexOf("# Example Post")).toBeLessThan(contents.indexOf("## Second"));
|
|
62
|
+
expect(contents.endsWith("\n")).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from "@jest/globals";
|
|
2
|
+
import { containsFaqHeading, parseFaqEntries, renderFaqMarkdown, } from "../faq";
|
|
3
|
+
describe("faq", () => {
|
|
4
|
+
it("parses faq json entries", () => {
|
|
5
|
+
const entries = parseFaqEntries(JSON.stringify([
|
|
6
|
+
{ question: "What is this?", answer: "An FAQ." },
|
|
7
|
+
{ question: "", answer: "ignored" },
|
|
8
|
+
]));
|
|
9
|
+
expect(entries).toEqual([{ question: "What is this?", answer: "An FAQ." }]);
|
|
10
|
+
});
|
|
11
|
+
it("renders faq markdown", () => {
|
|
12
|
+
const markdown = renderFaqMarkdown([
|
|
13
|
+
{ question: "What is this?", answer: "An FAQ." },
|
|
14
|
+
]);
|
|
15
|
+
expect(markdown).toContain("## FAQ");
|
|
16
|
+
expect(markdown).toContain("### What is this?");
|
|
17
|
+
expect(markdown).toContain("An FAQ.");
|
|
18
|
+
});
|
|
19
|
+
it("detects existing faq headings", () => {
|
|
20
|
+
expect(containsFaqHeading("## FAQ\n\nHello\n")).toBe(true);
|
|
21
|
+
expect(containsFaqHeading("# Not FAQ\n")).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, expect, it } from "@jest/globals";
|
|
2
|
+
import { resolveMarkdownFilename, slugify, uniqueName } from "../filename";
|
|
3
|
+
describe("filename", () => {
|
|
4
|
+
it("slugify normalizes text", () => {
|
|
5
|
+
expect(slugify("Hello, Framer CMS!")).toBe("hello-framer-cms");
|
|
6
|
+
});
|
|
7
|
+
it("uniqueName adds suffixes", () => {
|
|
8
|
+
const existing = new Set(["post", "post-2"]);
|
|
9
|
+
expect(uniqueName("post", existing)).toBe("post-3");
|
|
10
|
+
});
|
|
11
|
+
it("resolveMarkdownFilename prefers slug", () => {
|
|
12
|
+
const existing = new Set();
|
|
13
|
+
expect(resolveMarkdownFilename({ slug: "hello-world" }, existing)).toBe("hello-world.md");
|
|
14
|
+
});
|
|
15
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const globals_1 = require("@jest/globals");
|
|
4
|
+
const framer_ref_1 = require("../framer-ref");
|
|
5
|
+
(0, globals_1.describe)("framer ref", () => {
|
|
6
|
+
(0, globals_1.it)("extracts the project id from a framer project url", () => {
|
|
7
|
+
(0, globals_1.expect)((0, framer_ref_1.normalizeFramerProjectRef)("https://framer.com/projects/NEXT-Production-v2--JCo5wuC0juGVNOJSk41P-dcViX")).toBe("JCo5wuC0juGVNOJSk41P-dcViX");
|
|
8
|
+
});
|
|
9
|
+
(0, globals_1.it)("keeps a bare project id unchanged", () => {
|
|
10
|
+
(0, globals_1.expect)((0, framer_ref_1.normalizeFramerProjectRef)("JCo5wuC0juGVNOJSk41P-dcViX")).toBe("JCo5wuC0juGVNOJSk41P-dcViX");
|
|
11
|
+
});
|
|
12
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, expect, it } from "@jest/globals";
|
|
2
|
+
import { toFrontmatter } from "../frontmatter";
|
|
3
|
+
describe("frontmatter", () => {
|
|
4
|
+
it("serializes yaml frontmatter", () => {
|
|
5
|
+
const output = toFrontmatter({
|
|
6
|
+
title: "Hello",
|
|
7
|
+
tags: ["a", "b"],
|
|
8
|
+
published: true,
|
|
9
|
+
});
|
|
10
|
+
expect(output).toMatch(/^---\n/);
|
|
11
|
+
expect(output).toMatch(/title: Hello/);
|
|
12
|
+
expect(output).toMatch(/tags:/);
|
|
13
|
+
expect(output).toMatch(/published: true/);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, it } from "@jest/globals";
|
|
2
|
+
import { htmlToMarkdown } from "../markdown";
|
|
3
|
+
describe("markdown", () => {
|
|
4
|
+
it("converts simple html", async () => {
|
|
5
|
+
const output = await htmlToMarkdown("<h1>Hello</h1><p>World</p>");
|
|
6
|
+
expect(output).toMatch(/^# Hello/);
|
|
7
|
+
expect(output).toMatch(/World/);
|
|
8
|
+
});
|
|
9
|
+
it("converts html tables to markdown tables", async () => {
|
|
10
|
+
const output = await htmlToMarkdown([
|
|
11
|
+
"<figure><table><tbody>",
|
|
12
|
+
"<tr><th><p>Approach</p></th><th><p>Effect</p></th></tr>",
|
|
13
|
+
"<tr><td><p>Fix early</p></td><td><p>Cheaper remediation</p></td></tr>",
|
|
14
|
+
"</tbody></table></figure>",
|
|
15
|
+
].join(""));
|
|
16
|
+
expect(output).toMatch(/\|\s*Approach\s*\|\s*Effect\s*\|/);
|
|
17
|
+
expect(output).toMatch(/\|\s*-+\s*\|\s*-+\s*\|/);
|
|
18
|
+
expect(output).toMatch(/\|\s*Fix early\s*\|\s*Cheaper remediation\s*\|/);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { loadConfig } from "../libs/framer/config";
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { resolveMarkdownFilename, slugify } from "./filename";
|
|
4
|
+
import { formatMarkdownDocument } from "./format";
|
|
5
|
+
import { toFrontmatter } from "./frontmatter";
|
|
6
|
+
import { htmlToMarkdown } from "./markdown";
|
|
7
|
+
const SECTION_HTML_FIELD_PATTERN = /^Section\s+(\d+)\s+HTML$/i;
|
|
8
|
+
const BODY_FIELD_CANDIDATES = [
|
|
9
|
+
"content",
|
|
10
|
+
"body",
|
|
11
|
+
"html",
|
|
12
|
+
"richText",
|
|
13
|
+
"rich_text",
|
|
14
|
+
];
|
|
15
|
+
function stringValue(value) {
|
|
16
|
+
return typeof value === "string" ? value : "";
|
|
17
|
+
}
|
|
18
|
+
function looksLikeHtml(value) {
|
|
19
|
+
return /<\/?[a-z][\s\S]*>/i.test(value);
|
|
20
|
+
}
|
|
21
|
+
function normalizeFrontmatterValue(value) {
|
|
22
|
+
if (value instanceof Date) {
|
|
23
|
+
return value.toISOString();
|
|
24
|
+
}
|
|
25
|
+
if (Array.isArray(value)) {
|
|
26
|
+
return value.map(normalizeFrontmatterValue);
|
|
27
|
+
}
|
|
28
|
+
if (value && typeof value === "object") {
|
|
29
|
+
const record = {};
|
|
30
|
+
for (const [key, nestedValue] of Object.entries(value)) {
|
|
31
|
+
if (nestedValue !== undefined) {
|
|
32
|
+
record[key] = normalizeFrontmatterValue(nestedValue);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return record;
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
export function resolveBodyField(item, bodyFieldName) {
|
|
40
|
+
if (bodyFieldName === "sections") {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
if (bodyFieldName && item.fieldData[bodyFieldName] !== undefined) {
|
|
44
|
+
return bodyFieldName;
|
|
45
|
+
}
|
|
46
|
+
for (const candidate of BODY_FIELD_CANDIDATES) {
|
|
47
|
+
const value = item.fieldData[candidate];
|
|
48
|
+
if (typeof value === "string" && stringValue(value).trim().length > 0) {
|
|
49
|
+
return candidate;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
for (const [key, value] of Object.entries(item.fieldData)) {
|
|
53
|
+
if (typeof value === "string" && looksLikeHtml(value)) {
|
|
54
|
+
return key;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
export function resolveSectionFields(item) {
|
|
60
|
+
return Object.entries(item.fieldData)
|
|
61
|
+
.flatMap(([key, value]) => {
|
|
62
|
+
if (typeof value !== "string") {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
const match = key.match(SECTION_HTML_FIELD_PATTERN);
|
|
66
|
+
if (!match) {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
return [
|
|
70
|
+
{
|
|
71
|
+
name: key,
|
|
72
|
+
order: Number.parseInt(match[1], 10),
|
|
73
|
+
value,
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
})
|
|
77
|
+
.sort((left, right) => left.order - right.order || left.name.localeCompare(right.name));
|
|
78
|
+
}
|
|
79
|
+
export function buildFrontmatter(item, options) {
|
|
80
|
+
const frontmatter = {
|
|
81
|
+
slug: item.slug,
|
|
82
|
+
};
|
|
83
|
+
const { frontmatterFields } = options;
|
|
84
|
+
for (const fieldName of frontmatterFields ?? []) {
|
|
85
|
+
const value = item.fieldData[fieldName];
|
|
86
|
+
if (value === undefined ||
|
|
87
|
+
typeof value === "function" ||
|
|
88
|
+
typeof value === "symbol") {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
frontmatter[fieldName] = normalizeFrontmatterValue(value);
|
|
92
|
+
}
|
|
93
|
+
return frontmatter;
|
|
94
|
+
}
|
|
95
|
+
function stripMarkdownComments(markdown) {
|
|
96
|
+
return markdown.replace(/<!--[\s\S]*?-->\s*/g, "").trim();
|
|
97
|
+
}
|
|
98
|
+
async function renderBodyContent(value) {
|
|
99
|
+
const text = stringValue(value).trim();
|
|
100
|
+
if (!text) {
|
|
101
|
+
return "";
|
|
102
|
+
}
|
|
103
|
+
const markdown = looksLikeHtml(text) ? await htmlToMarkdown(text) : text;
|
|
104
|
+
const normalized = stripMarkdownComments(markdown);
|
|
105
|
+
return normalized.length > 0 ? `${normalized}\n` : "";
|
|
106
|
+
}
|
|
107
|
+
export function renderMarkdownBody(item, bodyFieldName) {
|
|
108
|
+
const sectionFields = resolveSectionFields(item);
|
|
109
|
+
if (bodyFieldName === "sections" || sectionFields.length > 0) {
|
|
110
|
+
return Promise.all(sectionFields.map((section) => renderBodyContent(section.value))).then((sections) => sections
|
|
111
|
+
.map((section) => section.trim())
|
|
112
|
+
.filter((section) => section.length > 0)
|
|
113
|
+
.join("\n\n")
|
|
114
|
+
.trim()
|
|
115
|
+
.concat(sectionFields.length > 0 ? "\n" : ""));
|
|
116
|
+
}
|
|
117
|
+
const bodyField = resolveBodyField(item, bodyFieldName);
|
|
118
|
+
return bodyField ? renderBodyContent(item.fieldData[bodyField]) : Promise.resolve("");
|
|
119
|
+
}
|
|
120
|
+
export async function exportItemsToMarkdown(items, options = {}) {
|
|
121
|
+
const outputDir = options.outputDir ??
|
|
122
|
+
path.join(process.cwd(), "export", slugify(options.collectionName ?? "collection"));
|
|
123
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
124
|
+
const usedNames = new Set();
|
|
125
|
+
const writtenFiles = [];
|
|
126
|
+
for (const item of items) {
|
|
127
|
+
const filename = resolveMarkdownFilename(item, usedNames);
|
|
128
|
+
const filepath = path.join(outputDir, filename);
|
|
129
|
+
const frontmatter = buildFrontmatter(item, {
|
|
130
|
+
frontmatterFields: options.frontmatterFields,
|
|
131
|
+
});
|
|
132
|
+
const markdownBody = await renderMarkdownBody(item, options.bodyFieldName);
|
|
133
|
+
const fileContents = await formatMarkdownDocument(`${toFrontmatter(frontmatter)}${markdownBody}`);
|
|
134
|
+
await fs.writeFile(filepath, fileContents, "utf8");
|
|
135
|
+
writtenFiles.push(filepath);
|
|
136
|
+
}
|
|
137
|
+
return writtenFiles;
|
|
138
|
+
}
|