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,142 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createFieldDataEntryInput, } from "../libs/framer/framer-client";
|
|
4
|
+
import { slugify } from "../libs/framer/filename";
|
|
5
|
+
import { markdownToHtml, splitMarkdownIntoSections, } from "../libs/framer/markdown";
|
|
6
|
+
import { parseFrontmatterDocument } from "../libs/framer/frontmatter";
|
|
7
|
+
const SECTION_HTML_FIELD_PATTERN = /^Section\s+(\d+)\s+HTML$/i;
|
|
8
|
+
const SECTION_TITLE_FIELD_PATTERN = /^Section\s+(\d+)\s+Title$/i;
|
|
9
|
+
export async function listMarkdownFiles(rootDir) {
|
|
10
|
+
const entries = await fs.readdir(rootDir, { withFileTypes: true });
|
|
11
|
+
const files = await Promise.all(entries.map(async (entry) => {
|
|
12
|
+
const entryPath = path.join(rootDir, entry.name);
|
|
13
|
+
if (entry.isDirectory()) {
|
|
14
|
+
return listMarkdownFiles(entryPath);
|
|
15
|
+
}
|
|
16
|
+
return entry.isFile() && entry.name.endsWith(".md") ? [entryPath] : [];
|
|
17
|
+
}));
|
|
18
|
+
return files.flat().sort((left, right) => left.localeCompare(right));
|
|
19
|
+
}
|
|
20
|
+
export async function readMarkdownDocuments(contentDir) {
|
|
21
|
+
const files = await listMarkdownFiles(contentDir);
|
|
22
|
+
return Promise.all(files.map(async (filePath) => {
|
|
23
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
24
|
+
const { data, body } = parseFrontmatterDocument(raw);
|
|
25
|
+
const slug = typeof data.slug === "string" && data.slug.trim().length > 0
|
|
26
|
+
? data.slug
|
|
27
|
+
: slugify(path.basename(filePath, ".md"));
|
|
28
|
+
return {
|
|
29
|
+
path: filePath,
|
|
30
|
+
slug,
|
|
31
|
+
metadata: data,
|
|
32
|
+
body,
|
|
33
|
+
};
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
export function resolveSectionHtmlFields(fields) {
|
|
37
|
+
return fields
|
|
38
|
+
.filter((field) => {
|
|
39
|
+
return field.type === "formattedText" && SECTION_HTML_FIELD_PATTERN.test(field.name);
|
|
40
|
+
})
|
|
41
|
+
.sort((left, right) => {
|
|
42
|
+
const leftOrder = Number.parseInt(left.name.match(SECTION_HTML_FIELD_PATTERN)?.[1] ?? "0", 10);
|
|
43
|
+
const rightOrder = Number.parseInt(right.name.match(SECTION_HTML_FIELD_PATTERN)?.[1] ?? "0", 10);
|
|
44
|
+
return leftOrder - rightOrder || left.name.localeCompare(right.name);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
export function resolveSectionTitleFields(fields) {
|
|
48
|
+
return fields
|
|
49
|
+
.filter((field) => SECTION_TITLE_FIELD_PATTERN.test(field.name))
|
|
50
|
+
.sort((left, right) => {
|
|
51
|
+
const leftOrder = Number.parseInt(left.name.match(SECTION_TITLE_FIELD_PATTERN)?.[1] ?? "0", 10);
|
|
52
|
+
const rightOrder = Number.parseInt(right.name.match(SECTION_TITLE_FIELD_PATTERN)?.[1] ?? "0", 10);
|
|
53
|
+
return leftOrder - rightOrder || left.name.localeCompare(right.name);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
export function extractSectionTitle(markdown) {
|
|
57
|
+
const match = markdown.match(/^#{1,6}\s+(.+?)\s*$/m);
|
|
58
|
+
if (!match) {
|
|
59
|
+
return "";
|
|
60
|
+
}
|
|
61
|
+
return match[1].replace(/\s+#+\s*$/, "").trim();
|
|
62
|
+
}
|
|
63
|
+
export async function buildFieldDataInput(document, fields, config) {
|
|
64
|
+
const fieldByName = new Map(fields.map((field) => [field.name, field]));
|
|
65
|
+
const fieldData = {};
|
|
66
|
+
for (const metadataFieldName of config.metadata) {
|
|
67
|
+
const field = fieldByName.get(metadataFieldName);
|
|
68
|
+
if (!field) {
|
|
69
|
+
throw new Error(`Field "${metadataFieldName}" from config was not found in Framer collection.`);
|
|
70
|
+
}
|
|
71
|
+
const value = document.metadata[metadataFieldName];
|
|
72
|
+
if (value === undefined) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const input = createFieldDataEntryInput(field, value, {
|
|
76
|
+
formattedTextContentType: "markdown",
|
|
77
|
+
});
|
|
78
|
+
if (!input) {
|
|
79
|
+
throw new Error(`Field "${metadataFieldName}" could not be populated from frontmatter value ${JSON.stringify(value)}.`);
|
|
80
|
+
}
|
|
81
|
+
fieldData[field.id] = input;
|
|
82
|
+
}
|
|
83
|
+
if (config.markdown === "sections") {
|
|
84
|
+
const sectionFields = resolveSectionHtmlFields(fields);
|
|
85
|
+
const titleFields = resolveSectionTitleFields(fields);
|
|
86
|
+
const sections = splitMarkdownIntoSections(document.body);
|
|
87
|
+
if (sections.length > sectionFields.length) {
|
|
88
|
+
throw new Error(`Document "${document.path}" has ${sections.length} sections, but Framer collection only exposes ${sectionFields.length} "Section NN HTML" fields.`);
|
|
89
|
+
}
|
|
90
|
+
for (const field of sectionFields) {
|
|
91
|
+
fieldData[field.id] = {
|
|
92
|
+
type: field.type,
|
|
93
|
+
value: "",
|
|
94
|
+
contentType: "html",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
for (const field of titleFields) {
|
|
98
|
+
const input = createFieldDataEntryInput(field, "");
|
|
99
|
+
if (input) {
|
|
100
|
+
fieldData[field.id] = input;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
for (const [index, section] of sections.entries()) {
|
|
104
|
+
const field = sectionFields[index];
|
|
105
|
+
const titleField = titleFields[index];
|
|
106
|
+
const html = await markdownToHtml(section);
|
|
107
|
+
const title = extractSectionTitle(section);
|
|
108
|
+
fieldData[field.id] = {
|
|
109
|
+
type: field.type,
|
|
110
|
+
value: html,
|
|
111
|
+
contentType: "html",
|
|
112
|
+
};
|
|
113
|
+
if (titleField) {
|
|
114
|
+
const input = createFieldDataEntryInput(titleField, title);
|
|
115
|
+
if (input) {
|
|
116
|
+
fieldData[titleField.id] = input;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return fieldData;
|
|
121
|
+
}
|
|
122
|
+
const markdownField = fieldByName.get(config.markdown);
|
|
123
|
+
if (!markdownField) {
|
|
124
|
+
throw new Error(`Markdown field "${config.markdown}" from config was not found in Framer collection.`);
|
|
125
|
+
}
|
|
126
|
+
const bodyInput = createFieldDataEntryInput(markdownField, document.body, {
|
|
127
|
+
formattedTextContentType: "markdown",
|
|
128
|
+
});
|
|
129
|
+
if (!bodyInput) {
|
|
130
|
+
throw new Error(`Markdown field "${config.markdown}" could not accept the document body.`);
|
|
131
|
+
}
|
|
132
|
+
fieldData[markdownField.id] = bodyInput;
|
|
133
|
+
return fieldData;
|
|
134
|
+
}
|
|
135
|
+
export async function buildCollectionItemInput(document, fields, config, existingItemsBySlug) {
|
|
136
|
+
const existing = existingItemsBySlug.get(document.slug);
|
|
137
|
+
return {
|
|
138
|
+
id: existing?.id,
|
|
139
|
+
slug: document.slug,
|
|
140
|
+
fieldData: await buildFieldDataInput(document, fields, config),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import yargs from "yargs";
|
|
4
|
+
import { hideBin } from "yargs/helpers";
|
|
5
|
+
import { loadConfig } from "../libs/framer/config";
|
|
6
|
+
import { FramerClient, } from "../libs/framer/framer-client";
|
|
7
|
+
import { buildCollectionItemInput, readMarkdownDocuments, } from "./import";
|
|
8
|
+
export async function runCli(argv = hideBin(process.argv)) {
|
|
9
|
+
const args = await yargs(argv)
|
|
10
|
+
.scriptName("markdown-to-framer")
|
|
11
|
+
.usage("$0 --token <token> --config config/use-cases.yml")
|
|
12
|
+
.option("token", {
|
|
13
|
+
type: "string",
|
|
14
|
+
describe: "Framer API token. Falls back to FRAMER_TOKEN.",
|
|
15
|
+
})
|
|
16
|
+
.option("config", {
|
|
17
|
+
type: "string",
|
|
18
|
+
demandOption: true,
|
|
19
|
+
describe: "Path to a single sync config file",
|
|
20
|
+
})
|
|
21
|
+
.strict()
|
|
22
|
+
.help()
|
|
23
|
+
.parse();
|
|
24
|
+
const cliArgs = args;
|
|
25
|
+
const token = cliArgs.token ?? process.env.FRAMER_TOKEN;
|
|
26
|
+
if (!token) {
|
|
27
|
+
throw new Error("Framer token is required. Pass --token or set FRAMER_TOKEN.");
|
|
28
|
+
}
|
|
29
|
+
const { config } = await loadConfig(cliArgs.config);
|
|
30
|
+
const contentDir = path.resolve(config.content ?? path.join("content", config.collection));
|
|
31
|
+
const documents = await readMarkdownDocuments(contentDir);
|
|
32
|
+
const client = new FramerClient({
|
|
33
|
+
projectRef: config.site,
|
|
34
|
+
token,
|
|
35
|
+
});
|
|
36
|
+
try {
|
|
37
|
+
const { collection, fields, items } = await client.getCollectionExportData(config.collection);
|
|
38
|
+
const existingItemsBySlug = new Map(items.map((item) => [item.slug, item]));
|
|
39
|
+
const upserts = [];
|
|
40
|
+
for (const document of documents) {
|
|
41
|
+
upserts.push(await buildCollectionItemInput(document, fields, config, existingItemsBySlug));
|
|
42
|
+
}
|
|
43
|
+
await client.upsertCollectionItems(collection, upserts);
|
|
44
|
+
for (const document of documents) {
|
|
45
|
+
console.log(document.slug);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
await client.disconnect();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.htmlToMarkdown = htmlToMarkdown;
|
|
7
|
+
const turndown_1 = __importDefault(require("turndown"));
|
|
8
|
+
const turndown = new turndown_1.default({
|
|
9
|
+
headingStyle: "atx",
|
|
10
|
+
codeBlockStyle: "fenced",
|
|
11
|
+
bulletListMarker: "-",
|
|
12
|
+
emDelimiter: "_",
|
|
13
|
+
});
|
|
14
|
+
function htmlToMarkdown(html) {
|
|
15
|
+
const input = html.trim();
|
|
16
|
+
if (!input) {
|
|
17
|
+
return "";
|
|
18
|
+
}
|
|
19
|
+
const markdown = turndown.turndown(input).trim();
|
|
20
|
+
return markdown.length > 0 ? `${markdown}\n` : "";
|
|
21
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const strict_1 = __importDefault(require("node:assert/strict"));
|
|
7
|
+
const node_test_1 = __importDefault(require("node:test"));
|
|
8
|
+
const filename_1 = require("../src/filename");
|
|
9
|
+
(0, node_test_1.default)("slugify normalizes text", () => {
|
|
10
|
+
strict_1.default.equal((0, filename_1.slugify)("Hello, Framer CMS!"), "hello-framer-cms");
|
|
11
|
+
});
|
|
12
|
+
(0, node_test_1.default)("uniqueName adds suffixes", () => {
|
|
13
|
+
const existing = new Set(["post", "post-2"]);
|
|
14
|
+
strict_1.default.equal((0, filename_1.uniqueName)("post", existing), "post-3");
|
|
15
|
+
});
|
|
16
|
+
(0, node_test_1.default)("resolveMarkdownFilename prefers slug", () => {
|
|
17
|
+
const existing = new Set();
|
|
18
|
+
strict_1.default.equal((0, filename_1.resolveMarkdownFilename)({ slug: "hello-world" }, existing), "hello-world.md");
|
|
19
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const strict_1 = __importDefault(require("node:assert/strict"));
|
|
7
|
+
const node_test_1 = __importDefault(require("node:test"));
|
|
8
|
+
const frontmatter_1 = require("../src/frontmatter");
|
|
9
|
+
(0, node_test_1.default)("toFrontmatter serializes yaml frontmatter", () => {
|
|
10
|
+
const output = (0, frontmatter_1.toFrontmatter)({
|
|
11
|
+
title: "Hello",
|
|
12
|
+
tags: ["a", "b"],
|
|
13
|
+
published: true,
|
|
14
|
+
});
|
|
15
|
+
strict_1.default.match(output, /^---\n/);
|
|
16
|
+
strict_1.default.match(output, /title: Hello/);
|
|
17
|
+
strict_1.default.match(output, /tags:/);
|
|
18
|
+
strict_1.default.match(output, /published: true/);
|
|
19
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const strict_1 = __importDefault(require("node:assert/strict"));
|
|
7
|
+
const node_test_1 = __importDefault(require("node:test"));
|
|
8
|
+
const markdown_1 = require("../src/markdown");
|
|
9
|
+
(0, node_test_1.default)("htmlToMarkdown converts simple html", () => {
|
|
10
|
+
const output = (0, markdown_1.htmlToMarkdown)("<h1>Hello</h1><p>World</p>");
|
|
11
|
+
strict_1.default.match(output, /^# Hello/);
|
|
12
|
+
strict_1.default.match(output, /World/);
|
|
13
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "framer-cms-markdown",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Sync's Framer CMS content from/to markdown",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/src/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"framer-to-markdown": "dist/src/framer-to-markdown/cli.js",
|
|
9
|
+
"markdown-to-framer": "dist/src/markdown-to-framer/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc -p tsconfig.json",
|
|
13
|
+
"lint": "eslint --fix \"**/*.ts\"",
|
|
14
|
+
"test": "npm run test:unit",
|
|
15
|
+
"test:unit": "NODE_OPTIONS='--experimental-vm-modules --max_old_space_size=3072' jest --maxWorkers=50%",
|
|
16
|
+
"framer-to-markdown": "AWS_SDK_LOAD_CONFIG=1 tsx -r source-map-support/register src/framer-to-markdown/cli.ts",
|
|
17
|
+
"markdown-to-framer": "AWS_SDK_LOAD_CONFIG=1 tsx -r source-map-support/register src/markdown-to-framer/cli.ts"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [],
|
|
20
|
+
"author": "Ronny Roeller <ronny@nextapp.co> (nextapp.co)",
|
|
21
|
+
"license": "ISC",
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"framer-api": "^0.1.14",
|
|
24
|
+
"prettier": "^3.8.4",
|
|
25
|
+
"rehype-parse": "^9.0.1",
|
|
26
|
+
"rehype-remark": "^10.0.1",
|
|
27
|
+
"rehype-stringify": "^10.0.1",
|
|
28
|
+
"remark-gfm": "^4.0.1",
|
|
29
|
+
"remark-parse": "^11.0.0",
|
|
30
|
+
"remark-rehype": "^11.1.2",
|
|
31
|
+
"remark-stringify": "^11.0.0",
|
|
32
|
+
"unified": "^11.0.5",
|
|
33
|
+
"yaml": "^2.8.1",
|
|
34
|
+
"yargs": "^18.0.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/jest": "^30.0.0",
|
|
38
|
+
"@types/node": "^24.0.10",
|
|
39
|
+
"eslint": "^8.57.1",
|
|
40
|
+
"eslint-config-collaborne": "^5.6.0",
|
|
41
|
+
"jest": "^30.4.2",
|
|
42
|
+
"ts-jest": "^29.4.11",
|
|
43
|
+
"tsx": "^4.22.4",
|
|
44
|
+
"typescript": "^6.0.3"
|
|
45
|
+
},
|
|
46
|
+
"jest": {
|
|
47
|
+
"preset": "ts-jest/presets/js-with-ts-esm",
|
|
48
|
+
"globals": {
|
|
49
|
+
"ts-jest": {
|
|
50
|
+
"useESM": true
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"extensionsToTreatAsEsm": [
|
|
54
|
+
".ts",
|
|
55
|
+
".tsx"
|
|
56
|
+
],
|
|
57
|
+
"testEnvironment": "node",
|
|
58
|
+
"testTimeout": 20000,
|
|
59
|
+
"testMatch": [
|
|
60
|
+
"**/*.spec.ts"
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, it } from "@jest/globals";
|
|
6
|
+
|
|
7
|
+
import { exportItemsToMarkdown } from "../export";
|
|
8
|
+
|
|
9
|
+
const tempDirs: string[] = [];
|
|
10
|
+
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
await Promise.all(
|
|
13
|
+
tempDirs
|
|
14
|
+
.splice(0)
|
|
15
|
+
.map((dir) => fs.rm(dir, { recursive: true, force: true })),
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("export", () => {
|
|
20
|
+
it("writes the configured markdown body and curated frontmatter", async () => {
|
|
21
|
+
const outputDir = await fs.mkdtemp(
|
|
22
|
+
path.join(os.tmpdir(), "framer-cms-markdown-"),
|
|
23
|
+
);
|
|
24
|
+
tempDirs.push(outputDir);
|
|
25
|
+
|
|
26
|
+
const [writtenFile] = await exportItemsToMarkdown(
|
|
27
|
+
[
|
|
28
|
+
{
|
|
29
|
+
id: "1",
|
|
30
|
+
slug: "example-post",
|
|
31
|
+
draft: false,
|
|
32
|
+
fieldData: {
|
|
33
|
+
Title: "Example Post",
|
|
34
|
+
Excerpt: "Short summary",
|
|
35
|
+
"Section 02 HTML":
|
|
36
|
+
'<h2 dir="auto">Second</h2><p dir="auto">Hello world.</p>',
|
|
37
|
+
"Section 01 HTML":
|
|
38
|
+
'<!-- internal metadata --><h1 dir="auto">Example Post</h1><p dir="auto">Intro text.</p>',
|
|
39
|
+
"Workflow Archetype": "continuous_enrichment",
|
|
40
|
+
"Primary Team": "Product",
|
|
41
|
+
"Company Type": "SaaS",
|
|
42
|
+
"Strategic Category": "Product Intelligence",
|
|
43
|
+
"Business Outcome": "Improve roadmap prioritization",
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
{
|
|
48
|
+
outputDir,
|
|
49
|
+
bodyFieldName: "sections",
|
|
50
|
+
frontmatterFields: [
|
|
51
|
+
"Title",
|
|
52
|
+
"Excerpt",
|
|
53
|
+
"Workflow Archetype",
|
|
54
|
+
"Primary Team",
|
|
55
|
+
"Company Type",
|
|
56
|
+
"Strategic Category",
|
|
57
|
+
"Business Outcome",
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const contents = await fs.readFile(writtenFile, "utf8");
|
|
63
|
+
|
|
64
|
+
expect(contents).toContain("slug: example-post");
|
|
65
|
+
expect(contents).toContain("Title: Example Post");
|
|
66
|
+
expect(contents).toContain("Excerpt: Short summary");
|
|
67
|
+
expect(contents).toContain("Workflow Archetype: continuous_enrichment");
|
|
68
|
+
expect(contents).toContain("Primary Team: Product");
|
|
69
|
+
expect(contents).toContain("Company Type: SaaS");
|
|
70
|
+
expect(contents).toContain("Strategic Category: Product Intelligence");
|
|
71
|
+
expect(contents).toContain(
|
|
72
|
+
"Business Outcome: Improve roadmap prioritization",
|
|
73
|
+
);
|
|
74
|
+
expect(contents).not.toContain("draft:");
|
|
75
|
+
expect(contents).not.toContain("internal metadata");
|
|
76
|
+
expect(contents).toContain("# Example Post");
|
|
77
|
+
expect(contents).toContain("Intro text.");
|
|
78
|
+
expect(contents).toContain("## Second");
|
|
79
|
+
expect(contents).toContain("Hello world.");
|
|
80
|
+
expect(contents.indexOf("# Example Post")).toBeLessThan(
|
|
81
|
+
contents.indexOf("## Second"),
|
|
82
|
+
);
|
|
83
|
+
expect(contents.endsWith("\n")).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from "@jest/globals";
|
|
2
|
+
|
|
3
|
+
import { resolveMarkdownFilename, slugify, uniqueName } from "../filename";
|
|
4
|
+
|
|
5
|
+
describe("filename", () => {
|
|
6
|
+
it("slugify normalizes text", () => {
|
|
7
|
+
expect(slugify("Hello, Framer CMS!")).toBe("hello-framer-cms");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("uniqueName adds suffixes", () => {
|
|
11
|
+
const existing = new Set(["post", "post-2"]);
|
|
12
|
+
expect(uniqueName("post", existing)).toBe("post-3");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("resolveMarkdownFilename prefers slug", () => {
|
|
16
|
+
const existing = new Set<string>();
|
|
17
|
+
expect(resolveMarkdownFilename({ slug: "hello-world" }, existing)).toBe(
|
|
18
|
+
"hello-world.md",
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, it } from "@jest/globals";
|
|
2
|
+
|
|
3
|
+
import { toFrontmatter } from "../frontmatter";
|
|
4
|
+
|
|
5
|
+
describe("frontmatter", () => {
|
|
6
|
+
it("serializes yaml frontmatter", () => {
|
|
7
|
+
const output = toFrontmatter({
|
|
8
|
+
title: "Hello",
|
|
9
|
+
tags: ["a", "b"],
|
|
10
|
+
published: true,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
expect(output).toMatch(/^---\n/);
|
|
14
|
+
expect(output).toMatch(/title: Hello/);
|
|
15
|
+
expect(output).toMatch(/tags:/);
|
|
16
|
+
expect(output).toMatch(/published: true/);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from "@jest/globals";
|
|
2
|
+
|
|
3
|
+
import { htmlToMarkdown } from "../markdown";
|
|
4
|
+
|
|
5
|
+
describe("markdown", () => {
|
|
6
|
+
it("converts simple html", async () => {
|
|
7
|
+
const output = await htmlToMarkdown("<h1>Hello</h1><p>World</p>");
|
|
8
|
+
expect(output).toMatch(/^# Hello/);
|
|
9
|
+
expect(output).toMatch(/World/);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("converts html tables to markdown tables", async () => {
|
|
13
|
+
const output = await htmlToMarkdown(
|
|
14
|
+
[
|
|
15
|
+
"<figure><table><tbody>",
|
|
16
|
+
"<tr><th><p>Approach</p></th><th><p>Effect</p></th></tr>",
|
|
17
|
+
"<tr><td><p>Fix early</p></td><td><p>Cheaper remediation</p></td></tr>",
|
|
18
|
+
"</tbody></table></figure>",
|
|
19
|
+
].join(""),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
expect(output).toMatch(/\|\s*Approach\s*\|\s*Effect\s*\|/);
|
|
23
|
+
expect(output).toMatch(/\|\s*-+\s*\|\s*-+\s*\|/);
|
|
24
|
+
expect(output).toMatch(/\|\s*Fix early\s*\|\s*Cheaper remediation\s*\|/);
|
|
25
|
+
});
|
|
26
|
+
});
|