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,44 @@
|
|
|
1
|
+
function isRecord(value) {
|
|
2
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
export function parseFaqEntries(value) {
|
|
5
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
6
|
+
return [];
|
|
7
|
+
}
|
|
8
|
+
let parsed;
|
|
9
|
+
try {
|
|
10
|
+
parsed = JSON.parse(value);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
if (!Array.isArray(parsed)) {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
return parsed.flatMap((entry) => {
|
|
19
|
+
if (!isRecord(entry)) {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
const question = typeof entry.question === "string" ? entry.question.trim() : "";
|
|
23
|
+
const answer = typeof entry.answer === "string" ? entry.answer.trim() : "";
|
|
24
|
+
if (!question || !answer) {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
return [{ question, answer }];
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
export function containsFaqHeading(markdown, heading = "FAQ") {
|
|
31
|
+
const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
32
|
+
const pattern = new RegExp(`^#{2,6}\\s+${escapedHeading}\\s*$`, "im");
|
|
33
|
+
return pattern.test(markdown);
|
|
34
|
+
}
|
|
35
|
+
export function renderFaqMarkdown(entries, heading = "FAQ") {
|
|
36
|
+
if (entries.length === 0) {
|
|
37
|
+
return "";
|
|
38
|
+
}
|
|
39
|
+
const lines = [`## ${heading}`, ""];
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
lines.push(`### ${entry.question}`, "", entry.answer, "");
|
|
42
|
+
}
|
|
43
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
44
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { resolveMarkdownFilename, slugify, uniqueName, } from "../libs/framer/filename";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { formatMarkdownDocument } from "../libs/framer/format";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createFieldDataEntryInput, FramerClient, } from "../libs/framer/framer-client";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.normalizeFramerProjectRef = normalizeFramerProjectRef;
|
|
4
|
+
function extractFramerId(value) {
|
|
5
|
+
const trimmed = value.trim();
|
|
6
|
+
const parts = trimmed.split("--");
|
|
7
|
+
if (parts.length > 1) {
|
|
8
|
+
return parts[parts.length - 1] || trimmed;
|
|
9
|
+
}
|
|
10
|
+
return trimmed;
|
|
11
|
+
}
|
|
12
|
+
function normalizeFramerProjectRef(input) {
|
|
13
|
+
const trimmed = input.trim();
|
|
14
|
+
if (!trimmed) {
|
|
15
|
+
return trimmed;
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const parsed = new URL(trimmed);
|
|
19
|
+
const lastPathSegment = parsed.pathname.split("/").filter(Boolean).pop();
|
|
20
|
+
if (!lastPathSegment) {
|
|
21
|
+
return trimmed;
|
|
22
|
+
}
|
|
23
|
+
return extractFramerId(lastPathSegment);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return extractFramerId(trimmed);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { parseFrontmatterDocument, toFrontmatter } from "../libs/framer/frontmatter";
|
|
@@ -0,0 +1,49 @@
|
|
|
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 "./config";
|
|
6
|
+
import { exportItemsToMarkdown } from "./export";
|
|
7
|
+
import { FramerClient } from "./framer-client";
|
|
8
|
+
export async function runCli(argv = hideBin(process.argv)) {
|
|
9
|
+
const args = await yargs(argv)
|
|
10
|
+
.scriptName("framer-to-markdown")
|
|
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 export 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 client = new FramerClient({
|
|
31
|
+
projectRef: config.site,
|
|
32
|
+
token,
|
|
33
|
+
});
|
|
34
|
+
try {
|
|
35
|
+
const data = await client.getCollectionExportData(config.collection);
|
|
36
|
+
const writtenFiles = await exportItemsToMarkdown(data.items, {
|
|
37
|
+
outputDir: config.content ? path.resolve(config.content) : undefined,
|
|
38
|
+
collectionName: data.collection.name ?? data.collection.id,
|
|
39
|
+
bodyFieldName: config.markdown,
|
|
40
|
+
frontmatterFields: config.metadata,
|
|
41
|
+
});
|
|
42
|
+
for (const file of writtenFiles) {
|
|
43
|
+
console.log(file);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
await client.disconnect();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { htmlToMarkdown, markdownToHtml, splitMarkdownIntoSections, } from "../libs/framer/markdown";
|
|
@@ -0,0 +1,39 @@
|
|
|
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.toFrontmatter = toFrontmatter;
|
|
7
|
+
const yaml_1 = __importDefault(require("yaml"));
|
|
8
|
+
function toFrontmatter(data) {
|
|
9
|
+
const filtered = {};
|
|
10
|
+
for (const [key, value] of Object.entries(data)) {
|
|
11
|
+
if (value === undefined) {
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
filtered[key] = normalizeYamlValue(value);
|
|
15
|
+
}
|
|
16
|
+
const yaml = yaml_1.default.stringify(filtered, {
|
|
17
|
+
lineWidth: 0,
|
|
18
|
+
singleQuote: true,
|
|
19
|
+
});
|
|
20
|
+
return `---\n${yaml.trimEnd()}\n---\n\n`;
|
|
21
|
+
}
|
|
22
|
+
function normalizeYamlValue(value) {
|
|
23
|
+
if (value instanceof Date) {
|
|
24
|
+
return value.toISOString();
|
|
25
|
+
}
|
|
26
|
+
if (Array.isArray(value)) {
|
|
27
|
+
return value.map(normalizeYamlValue);
|
|
28
|
+
}
|
|
29
|
+
if (value && typeof value === "object") {
|
|
30
|
+
const output = {};
|
|
31
|
+
for (const [key, nestedValue] of Object.entries(value)) {
|
|
32
|
+
if (nestedValue !== undefined) {
|
|
33
|
+
output[key] = normalizeYamlValue(nestedValue);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return output;
|
|
37
|
+
}
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./cli"), exports);
|
|
18
|
+
__exportStar(require("./export"), exports);
|
|
19
|
+
__exportStar(require("./framer-client"), exports);
|
|
20
|
+
__exportStar(require("./frontmatter"), exports);
|
|
21
|
+
__exportStar(require("./markdown"), exports);
|
|
22
|
+
__exportStar(require("./filename"), exports);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import yaml from "yaml";
|
|
4
|
+
function isRecord(value) {
|
|
5
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
function parseMetadataConfig(value) {
|
|
8
|
+
if (!Array.isArray(value)) {
|
|
9
|
+
throw new Error('Config "metadata" must be an array.');
|
|
10
|
+
}
|
|
11
|
+
const fields = value.map((entry, index) => {
|
|
12
|
+
if (typeof entry !== "string" || entry.trim().length === 0) {
|
|
13
|
+
throw new Error(`Config "metadata[${index}]" must be a non-empty string.`);
|
|
14
|
+
}
|
|
15
|
+
return entry;
|
|
16
|
+
});
|
|
17
|
+
if (fields.length === 0) {
|
|
18
|
+
throw new Error('Config "metadata" must define at least one field.');
|
|
19
|
+
}
|
|
20
|
+
return fields;
|
|
21
|
+
}
|
|
22
|
+
export async function loadConfig(configPath) {
|
|
23
|
+
const resolvedPath = path.resolve(configPath);
|
|
24
|
+
const raw = await fs.readFile(resolvedPath, "utf8");
|
|
25
|
+
const parsed = yaml.parse(raw);
|
|
26
|
+
if (!isRecord(parsed)) {
|
|
27
|
+
throw new Error("Config file must contain a single object.");
|
|
28
|
+
}
|
|
29
|
+
if (typeof parsed.site !== "string" || parsed.site.trim().length === 0) {
|
|
30
|
+
throw new Error('Config "site" must be a non-empty string.');
|
|
31
|
+
}
|
|
32
|
+
if (typeof parsed.collection !== "string" ||
|
|
33
|
+
parsed.collection.trim().length === 0) {
|
|
34
|
+
throw new Error('Config "collection" must be a non-empty string.');
|
|
35
|
+
}
|
|
36
|
+
if (typeof parsed.markdown !== "string" ||
|
|
37
|
+
parsed.markdown.trim().length === 0) {
|
|
38
|
+
throw new Error('Config "markdown" must be a non-empty string.');
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
resolvedPath,
|
|
42
|
+
config: {
|
|
43
|
+
site: parsed.site,
|
|
44
|
+
collection: parsed.collection,
|
|
45
|
+
content: typeof parsed.content === "string" ? parsed.content : undefined,
|
|
46
|
+
markdown: parsed.markdown,
|
|
47
|
+
metadata: parseMetadataConfig(parsed.metadata),
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -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,69 @@
|
|
|
1
|
+
import { describe, expect, it } from "@jest/globals";
|
|
2
|
+
import { buildCollectionItemInput, resolveSectionHtmlFields, } from "../import";
|
|
3
|
+
function field(id, name, type) {
|
|
4
|
+
return { id, name, type };
|
|
5
|
+
}
|
|
6
|
+
describe("markdown-to-framer import", () => {
|
|
7
|
+
it("maps metadata verbatim and rebuilds ordered section html fields", async () => {
|
|
8
|
+
const document = {
|
|
9
|
+
path: "/tmp/example.md",
|
|
10
|
+
slug: "example-post",
|
|
11
|
+
metadata: {
|
|
12
|
+
Title: "Example Post",
|
|
13
|
+
Excerpt: "Short summary",
|
|
14
|
+
},
|
|
15
|
+
body: [
|
|
16
|
+
"Intro paragraph.",
|
|
17
|
+
"",
|
|
18
|
+
"## Second Section",
|
|
19
|
+
"",
|
|
20
|
+
"| A | B |",
|
|
21
|
+
"| --- | --- |",
|
|
22
|
+
"| 1 | 2 |",
|
|
23
|
+
].join("\n"),
|
|
24
|
+
};
|
|
25
|
+
const fields = [
|
|
26
|
+
field("title-id", "Title", "string"),
|
|
27
|
+
field("excerpt-id", "Excerpt", "string"),
|
|
28
|
+
field("s02-id", "Section 02 HTML", "formattedText"),
|
|
29
|
+
field("s01-id", "Section 01 HTML", "formattedText"),
|
|
30
|
+
field("s02-title-id", "Section 02 Title", "string"),
|
|
31
|
+
field("s01-title-id", "Section 01 Title", "string"),
|
|
32
|
+
];
|
|
33
|
+
expect(resolveSectionHtmlFields(fields).map((entry) => entry.name)).toEqual([
|
|
34
|
+
"Section 01 HTML",
|
|
35
|
+
"Section 02 HTML",
|
|
36
|
+
]);
|
|
37
|
+
const item = await buildCollectionItemInput(document, fields, {
|
|
38
|
+
markdown: "sections",
|
|
39
|
+
metadata: ["Title", "Excerpt"],
|
|
40
|
+
}, new Map());
|
|
41
|
+
expect(item.slug).toBe("example-post");
|
|
42
|
+
expect(item.fieldData?.["title-id"]).toEqual({
|
|
43
|
+
type: "string",
|
|
44
|
+
value: "Example Post",
|
|
45
|
+
});
|
|
46
|
+
expect(item.fieldData?.["excerpt-id"]).toEqual({
|
|
47
|
+
type: "string",
|
|
48
|
+
value: "Short summary",
|
|
49
|
+
});
|
|
50
|
+
expect(item.fieldData?.["s01-id"]).toMatchObject({
|
|
51
|
+
type: "formattedText",
|
|
52
|
+
contentType: "html",
|
|
53
|
+
});
|
|
54
|
+
expect(item.fieldData?.["s02-id"]).toMatchObject({
|
|
55
|
+
type: "formattedText",
|
|
56
|
+
contentType: "html",
|
|
57
|
+
});
|
|
58
|
+
expect(item.fieldData?.["s01-title-id"]).toEqual({
|
|
59
|
+
type: "string",
|
|
60
|
+
value: "",
|
|
61
|
+
});
|
|
62
|
+
expect(item.fieldData?.["s02-title-id"]).toEqual({
|
|
63
|
+
type: "string",
|
|
64
|
+
value: "Second Section",
|
|
65
|
+
});
|
|
66
|
+
expect((item.fieldData?.["s01-id"]).value).toContain("<p>Intro paragraph.</p>");
|
|
67
|
+
expect((item.fieldData?.["s02-id"]).value).toContain("<table>");
|
|
68
|
+
});
|
|
69
|
+
});
|