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,267 @@
|
|
|
1
|
+
import {
|
|
2
|
+
connect,
|
|
3
|
+
type Collection,
|
|
4
|
+
type CollectionItem,
|
|
5
|
+
type CollectionItemInput,
|
|
6
|
+
type Field,
|
|
7
|
+
type FieldDataEntryInput,
|
|
8
|
+
type Framer,
|
|
9
|
+
type FieldDataEntry,
|
|
10
|
+
} from "framer-api";
|
|
11
|
+
|
|
12
|
+
export type FramerClientOptions = {
|
|
13
|
+
projectRef: string;
|
|
14
|
+
token: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type FramerCollectionItemExport = {
|
|
18
|
+
id: string;
|
|
19
|
+
slug: string;
|
|
20
|
+
draft?: boolean;
|
|
21
|
+
fieldData: Record<string, unknown>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function extractAssetUrl(value: unknown): string | undefined {
|
|
25
|
+
if (!value || typeof value !== "object") {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const record = value as Record<string, unknown>;
|
|
30
|
+
return typeof record.url === "string" ? record.url : undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function extractIdOrString(value: unknown): string | undefined {
|
|
34
|
+
if (typeof value === "string") {
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!value || typeof value !== "object") {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const record = value as Record<string, unknown>;
|
|
43
|
+
if (typeof record.id === "string") {
|
|
44
|
+
return record.id;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (typeof record.name === "string") {
|
|
48
|
+
return record.name;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizeFieldEntry(entry: FieldDataEntry): unknown {
|
|
55
|
+
switch (entry.type) {
|
|
56
|
+
case "boolean":
|
|
57
|
+
case "number":
|
|
58
|
+
case "string":
|
|
59
|
+
case "enum":
|
|
60
|
+
case "link":
|
|
61
|
+
return entry.value;
|
|
62
|
+
case "date":
|
|
63
|
+
return entry.value ?? undefined;
|
|
64
|
+
case "formattedText":
|
|
65
|
+
return typeof entry.value === "string" ? entry.value : undefined;
|
|
66
|
+
case "image":
|
|
67
|
+
case "file":
|
|
68
|
+
return extractAssetUrl(entry.value);
|
|
69
|
+
case "color":
|
|
70
|
+
return extractIdOrString(entry.value);
|
|
71
|
+
case "collectionReference":
|
|
72
|
+
return entry.value ?? undefined;
|
|
73
|
+
case "multiCollectionReference":
|
|
74
|
+
return Array.isArray(entry.value) ? entry.value : undefined;
|
|
75
|
+
default:
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function createFieldDataEntryInput(
|
|
81
|
+
field: Field,
|
|
82
|
+
value: unknown,
|
|
83
|
+
options: {
|
|
84
|
+
formattedTextContentType?: "auto" | "markdown" | "html";
|
|
85
|
+
} = {},
|
|
86
|
+
): FieldDataEntryInput | undefined {
|
|
87
|
+
switch (field.type) {
|
|
88
|
+
case "boolean":
|
|
89
|
+
return typeof value === "boolean"
|
|
90
|
+
? { type: field.type, value }
|
|
91
|
+
: undefined;
|
|
92
|
+
case "number":
|
|
93
|
+
return typeof value === "number"
|
|
94
|
+
? { type: field.type, value }
|
|
95
|
+
: undefined;
|
|
96
|
+
case "string":
|
|
97
|
+
return typeof value === "string"
|
|
98
|
+
? { type: field.type, value }
|
|
99
|
+
: undefined;
|
|
100
|
+
case "formattedText":
|
|
101
|
+
return typeof value === "string"
|
|
102
|
+
? {
|
|
103
|
+
type: field.type,
|
|
104
|
+
value,
|
|
105
|
+
contentType: options.formattedTextContentType ?? "html",
|
|
106
|
+
}
|
|
107
|
+
: undefined;
|
|
108
|
+
case "enum":
|
|
109
|
+
return typeof value === "string"
|
|
110
|
+
? { type: field.type, value }
|
|
111
|
+
: undefined;
|
|
112
|
+
case "link":
|
|
113
|
+
return typeof value === "string"
|
|
114
|
+
? { type: field.type, value }
|
|
115
|
+
: undefined;
|
|
116
|
+
case "date":
|
|
117
|
+
return typeof value === "string" || typeof value === "number"
|
|
118
|
+
? { type: field.type, value: value as string | number }
|
|
119
|
+
: undefined;
|
|
120
|
+
case "collectionReference":
|
|
121
|
+
return typeof value === "string" || value === null
|
|
122
|
+
? { type: field.type, value: value as string | null }
|
|
123
|
+
: undefined;
|
|
124
|
+
case "multiCollectionReference":
|
|
125
|
+
return Array.isArray(value) &&
|
|
126
|
+
value.every((entry) => typeof entry === "string")
|
|
127
|
+
? { type: field.type, value: value as readonly string[] }
|
|
128
|
+
: undefined;
|
|
129
|
+
case "color":
|
|
130
|
+
case "file":
|
|
131
|
+
case "image":
|
|
132
|
+
return typeof value === "string" || value === null
|
|
133
|
+
? { type: field.type, value: value as string | null }
|
|
134
|
+
: undefined;
|
|
135
|
+
case "array":
|
|
136
|
+
case "divider":
|
|
137
|
+
case "unsupported":
|
|
138
|
+
return undefined;
|
|
139
|
+
default:
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export class FramerClient {
|
|
145
|
+
private readonly projectRef: string;
|
|
146
|
+
private readonly token: string;
|
|
147
|
+
private framer: Framer | undefined;
|
|
148
|
+
|
|
149
|
+
constructor(options: FramerClientOptions) {
|
|
150
|
+
this.projectRef = options.projectRef;
|
|
151
|
+
this.token = options.token;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async connect(): Promise<void> {
|
|
155
|
+
if (!this.framer) {
|
|
156
|
+
this.framer = await connect(this.projectRef, this.token);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async disconnect(): Promise<void> {
|
|
161
|
+
if (this.framer) {
|
|
162
|
+
await this.framer.disconnect();
|
|
163
|
+
this.framer = undefined;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async getCollections(): Promise<Collection[]> {
|
|
168
|
+
return (await this.getFramer()).getCollections();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async resolveCollection(collectionRef: string): Promise<Collection> {
|
|
172
|
+
const collections = await this.getCollections();
|
|
173
|
+
const normalizedRef = collectionRef.trim().toLowerCase();
|
|
174
|
+
const match = collections.find((collection) => {
|
|
175
|
+
return (
|
|
176
|
+
collection.id.toLowerCase() === normalizedRef ||
|
|
177
|
+
collection.name.toLowerCase() === normalizedRef
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
if (!match) {
|
|
182
|
+
const available = collections
|
|
183
|
+
.map((collection) => `${collection.name} (${collection.id})`)
|
|
184
|
+
.join(", ");
|
|
185
|
+
throw new Error(
|
|
186
|
+
`Collection "${collectionRef}" was not found. Available collections: ${available || "none"}.`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return match;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async getCollectionData(collectionRef: string): Promise<{
|
|
194
|
+
collection: Collection;
|
|
195
|
+
fields: Field[];
|
|
196
|
+
items: CollectionItem[];
|
|
197
|
+
}> {
|
|
198
|
+
const collection = await this.resolveCollection(collectionRef);
|
|
199
|
+
const fields = await collection.getFields();
|
|
200
|
+
const items = await collection.getItems();
|
|
201
|
+
|
|
202
|
+
return { collection, fields, items };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async getCollectionExportData(collectionRef: string): Promise<{
|
|
206
|
+
collection: Collection;
|
|
207
|
+
fields: Field[];
|
|
208
|
+
items: FramerCollectionItemExport[];
|
|
209
|
+
}> {
|
|
210
|
+
const { collection, fields, items } =
|
|
211
|
+
await this.getCollectionData(collectionRef);
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
collection,
|
|
215
|
+
fields,
|
|
216
|
+
items: items.map((item) => this.toExportItem(item, fields)),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async upsertCollectionItems(
|
|
221
|
+
collection: Collection,
|
|
222
|
+
items: CollectionItemInput[],
|
|
223
|
+
): Promise<void> {
|
|
224
|
+
await collection.addItems(items);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private async getFramer(): Promise<Framer> {
|
|
228
|
+
await this.connect();
|
|
229
|
+
if (!this.framer) {
|
|
230
|
+
throw new Error("Failed to connect to Framer");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return this.framer;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private toExportItem(
|
|
237
|
+
item: CollectionItem,
|
|
238
|
+
fields: Field[],
|
|
239
|
+
): FramerCollectionItemExport {
|
|
240
|
+
const fieldData: Record<string, unknown> = {};
|
|
241
|
+
|
|
242
|
+
for (const field of fields) {
|
|
243
|
+
if (field.type === "divider") {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const entry = item.fieldData[field.id];
|
|
248
|
+
if (!entry) {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const value = normalizeFieldEntry(entry);
|
|
253
|
+
if (value === undefined) {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
fieldData[field.name] = value;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
id: String(item.id),
|
|
262
|
+
slug: item.slug,
|
|
263
|
+
draft: item.draft,
|
|
264
|
+
fieldData,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import yaml from "yaml";
|
|
2
|
+
|
|
3
|
+
function normalizeYamlValue(value: unknown): unknown {
|
|
4
|
+
if (value instanceof Date) {
|
|
5
|
+
return value.toISOString();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
if (Array.isArray(value)) {
|
|
9
|
+
return value.map(normalizeYamlValue);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (value && typeof value === "object") {
|
|
13
|
+
const output: Record<string, unknown> = {};
|
|
14
|
+
for (const [key, nestedValue] of Object.entries(
|
|
15
|
+
value as Record<string, unknown>,
|
|
16
|
+
)) {
|
|
17
|
+
if (nestedValue !== undefined) {
|
|
18
|
+
output[key] = normalizeYamlValue(nestedValue);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return output;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function toFrontmatter(data: Record<string, unknown>): string {
|
|
29
|
+
const filtered: Record<string, unknown> = {};
|
|
30
|
+
|
|
31
|
+
for (const [key, value] of Object.entries(data)) {
|
|
32
|
+
if (value === undefined) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
filtered[key] = normalizeYamlValue(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const rendered = yaml.stringify(filtered, {
|
|
40
|
+
lineWidth: 0,
|
|
41
|
+
singleQuote: true,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return `---\n${rendered.trimEnd()}\n---\n\n`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function parseFrontmatterDocument(content: string): {
|
|
48
|
+
data: Record<string, unknown>;
|
|
49
|
+
body: string;
|
|
50
|
+
} {
|
|
51
|
+
const normalized = content.replace(/\r\n/g, "\n");
|
|
52
|
+
const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
53
|
+
|
|
54
|
+
if (!match) {
|
|
55
|
+
return { data: {}, body: normalized };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const [, rawFrontmatter, rawBody] = match;
|
|
59
|
+
const parsed = yaml.parse(rawFrontmatter) as unknown;
|
|
60
|
+
const data =
|
|
61
|
+
parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
62
|
+
? (parsed as Record<string, unknown>)
|
|
63
|
+
: {};
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
data,
|
|
67
|
+
body: rawBody.replace(/^\n+/, ""),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
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
|
+
|
|
10
|
+
const htmlToMarkdownProcessor = unified()
|
|
11
|
+
.use(rehypeParse, { fragment: true })
|
|
12
|
+
.use(rehypeRemark)
|
|
13
|
+
.use(remarkGfm, {
|
|
14
|
+
tablePipeAlign: true,
|
|
15
|
+
tableCellPadding: true,
|
|
16
|
+
})
|
|
17
|
+
.use(remarkStringify, {
|
|
18
|
+
bullet: "-",
|
|
19
|
+
emphasis: "_",
|
|
20
|
+
fences: true,
|
|
21
|
+
setext: false,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const markdownToHtmlProcessor = unified()
|
|
25
|
+
.use(remarkParse)
|
|
26
|
+
.use(remarkGfm)
|
|
27
|
+
.use(remarkRehype)
|
|
28
|
+
.use(rehypeStringify, { closeSelfClosing: true });
|
|
29
|
+
|
|
30
|
+
function normalizeFragment(markdown: string): string {
|
|
31
|
+
return markdown.trim().replace(/\n{3,}/g, "\n\n");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function stripMarkdownComments(markdown: string): string {
|
|
35
|
+
return markdown.replace(/<!--[\s\S]*?-->\s*/g, "").trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function htmlToMarkdown(html: string): Promise<string> {
|
|
39
|
+
const input = html.trim();
|
|
40
|
+
if (!input) {
|
|
41
|
+
return "";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const file = await htmlToMarkdownProcessor.process(input);
|
|
45
|
+
const markdown = normalizeFragment(String(file));
|
|
46
|
+
return markdown.length > 0 ? `${markdown}\n` : "";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function markdownToHtml(markdown: string): Promise<string> {
|
|
50
|
+
const input = stripMarkdownComments(markdown);
|
|
51
|
+
if (!input) {
|
|
52
|
+
return "";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const file = await markdownToHtmlProcessor.process(input);
|
|
56
|
+
return String(file).trim();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function splitMarkdownIntoSections(markdown: string): string[] {
|
|
60
|
+
const normalized = markdown.replace(/\r\n/g, "\n").trim();
|
|
61
|
+
if (!normalized) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const lines = normalized.split("\n");
|
|
66
|
+
const sections: string[] = [];
|
|
67
|
+
let current: string[] = [];
|
|
68
|
+
|
|
69
|
+
for (const line of lines) {
|
|
70
|
+
if (/^##\s+/.test(line) && current.length > 0) {
|
|
71
|
+
sections.push(current.join("\n").trim());
|
|
72
|
+
current = [line];
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
current.push(line);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (current.length > 0) {
|
|
80
|
+
sections.push(current.join("\n").trim());
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return sections.filter((section) => section.length > 0);
|
|
84
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, it } from "@jest/globals";
|
|
2
|
+
import type { Field } from "framer-api";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
buildCollectionItemInput,
|
|
6
|
+
resolveSectionHtmlFields,
|
|
7
|
+
type MarkdownDocument,
|
|
8
|
+
} from "../import";
|
|
9
|
+
|
|
10
|
+
function field(id: string, name: string, type: Field["type"]): Field {
|
|
11
|
+
return { id, name, type } as unknown as Field;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("markdown-to-framer import", () => {
|
|
15
|
+
it("maps metadata verbatim and rebuilds ordered section html fields", async () => {
|
|
16
|
+
const document: MarkdownDocument = {
|
|
17
|
+
path: "/tmp/example.md",
|
|
18
|
+
slug: "example-post",
|
|
19
|
+
metadata: {
|
|
20
|
+
Title: "Example Post",
|
|
21
|
+
Excerpt: "Short summary",
|
|
22
|
+
},
|
|
23
|
+
body: [
|
|
24
|
+
"Intro paragraph.",
|
|
25
|
+
"",
|
|
26
|
+
"## Second Section",
|
|
27
|
+
"",
|
|
28
|
+
"| A | B |",
|
|
29
|
+
"| --- | --- |",
|
|
30
|
+
"| 1 | 2 |",
|
|
31
|
+
].join("\n"),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const fields = [
|
|
35
|
+
field("title-id", "Title", "string"),
|
|
36
|
+
field("excerpt-id", "Excerpt", "string"),
|
|
37
|
+
field("s02-id", "Section 02 HTML", "formattedText"),
|
|
38
|
+
field("s01-id", "Section 01 HTML", "formattedText"),
|
|
39
|
+
field("s02-title-id", "Section 02 Title", "string"),
|
|
40
|
+
field("s01-title-id", "Section 01 Title", "string"),
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
expect(resolveSectionHtmlFields(fields).map((entry) => entry.name)).toEqual(
|
|
44
|
+
["Section 01 HTML", "Section 02 HTML"],
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const item = await buildCollectionItemInput(
|
|
48
|
+
document,
|
|
49
|
+
fields,
|
|
50
|
+
{
|
|
51
|
+
markdown: "sections",
|
|
52
|
+
metadata: ["Title", "Excerpt"],
|
|
53
|
+
},
|
|
54
|
+
new Map(),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
expect(item.slug).toBe("example-post");
|
|
58
|
+
expect(item.fieldData?.["title-id"]).toEqual({
|
|
59
|
+
type: "string",
|
|
60
|
+
value: "Example Post",
|
|
61
|
+
});
|
|
62
|
+
expect(item.fieldData?.["excerpt-id"]).toEqual({
|
|
63
|
+
type: "string",
|
|
64
|
+
value: "Short summary",
|
|
65
|
+
});
|
|
66
|
+
expect(item.fieldData?.["s01-id"]).toMatchObject({
|
|
67
|
+
type: "formattedText",
|
|
68
|
+
contentType: "html",
|
|
69
|
+
});
|
|
70
|
+
expect(item.fieldData?.["s02-id"]).toMatchObject({
|
|
71
|
+
type: "formattedText",
|
|
72
|
+
contentType: "html",
|
|
73
|
+
});
|
|
74
|
+
expect(item.fieldData?.["s01-title-id"]).toEqual({
|
|
75
|
+
type: "string",
|
|
76
|
+
value: "",
|
|
77
|
+
});
|
|
78
|
+
expect(item.fieldData?.["s02-title-id"]).toEqual({
|
|
79
|
+
type: "string",
|
|
80
|
+
value: "Second Section",
|
|
81
|
+
});
|
|
82
|
+
expect((item.fieldData?.["s01-id"] as { value: string }).value).toContain(
|
|
83
|
+
"<p>Intro paragraph.</p>",
|
|
84
|
+
);
|
|
85
|
+
expect((item.fieldData?.["s02-id"] as { value: string }).value).toContain(
|
|
86
|
+
"<table>",
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { markdownFaqToJsonLd } from "../json-ld-faq";
|
|
2
|
+
|
|
3
|
+
describe("markdownFaqToJsonLdScript", () => {
|
|
4
|
+
it("returns null when no FAQ section exists", () => {
|
|
5
|
+
const markdown = `
|
|
6
|
+
# Introduction
|
|
7
|
+
|
|
8
|
+
Some content.
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
More content.
|
|
13
|
+
`;
|
|
14
|
+
|
|
15
|
+
expect(markdownFaqToJsonLd(markdown)).toBeNull();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("converts a FAQ section into JSON-LD", () => {
|
|
19
|
+
const markdown = `
|
|
20
|
+
# Article
|
|
21
|
+
|
|
22
|
+
Some text.
|
|
23
|
+
|
|
24
|
+
## FAQ
|
|
25
|
+
|
|
26
|
+
### Question 1?
|
|
27
|
+
|
|
28
|
+
Answer 1.
|
|
29
|
+
|
|
30
|
+
### Question 2?
|
|
31
|
+
|
|
32
|
+
Answer 2.
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
const json = markdownFaqToJsonLd(markdown);
|
|
36
|
+
|
|
37
|
+
expect(json).not.toBeNull();
|
|
38
|
+
|
|
39
|
+
expect(json).toEqual({
|
|
40
|
+
"@context": "https://schema.org",
|
|
41
|
+
"@type": "FAQPage",
|
|
42
|
+
mainEntity: [
|
|
43
|
+
{
|
|
44
|
+
"@type": "Question",
|
|
45
|
+
name: "Question 1?",
|
|
46
|
+
acceptedAnswer: {
|
|
47
|
+
"@type": "Answer",
|
|
48
|
+
text: "Answer 1.",
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"@type": "Question",
|
|
53
|
+
name: "Question 2?",
|
|
54
|
+
acceptedAnswer: {
|
|
55
|
+
"@type": "Answer",
|
|
56
|
+
text: "Answer 2.",
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("supports FAQ at any heading level", () => {
|
|
64
|
+
const markdown = `
|
|
65
|
+
# Page
|
|
66
|
+
|
|
67
|
+
### FAQ
|
|
68
|
+
|
|
69
|
+
#### What is NEXT?
|
|
70
|
+
|
|
71
|
+
A platform for customer intelligence.
|
|
72
|
+
`;
|
|
73
|
+
|
|
74
|
+
const json = markdownFaqToJsonLd(markdown);
|
|
75
|
+
|
|
76
|
+
expect(json.mainEntity).toHaveLength(1);
|
|
77
|
+
expect(json.mainEntity[0].name).toBe("What is NEXT?");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("stops parsing at the next sibling section", () => {
|
|
81
|
+
const markdown = `
|
|
82
|
+
## FAQ
|
|
83
|
+
|
|
84
|
+
### Question 1?
|
|
85
|
+
|
|
86
|
+
Answer 1.
|
|
87
|
+
|
|
88
|
+
## Pricing
|
|
89
|
+
|
|
90
|
+
### Should not be included
|
|
91
|
+
|
|
92
|
+
Answer 2.
|
|
93
|
+
`;
|
|
94
|
+
|
|
95
|
+
const json = markdownFaqToJsonLd(markdown);
|
|
96
|
+
|
|
97
|
+
expect(json.mainEntity).toHaveLength(1);
|
|
98
|
+
expect(json.mainEntity[0].name).toBe("Question 1?");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("normalizes markdown inside answers", () => {
|
|
102
|
+
const markdown = `
|
|
103
|
+
## FAQ
|
|
104
|
+
|
|
105
|
+
### What is it?
|
|
106
|
+
|
|
107
|
+
This is **bold**, *italic*, and a [link](https://example.com).
|
|
108
|
+
`;
|
|
109
|
+
|
|
110
|
+
const json = markdownFaqToJsonLd(markdown);
|
|
111
|
+
|
|
112
|
+
expect(json.mainEntity[0].acceptedAnswer.text).toBe(
|
|
113
|
+
"This is bold, italic, and a link.",
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
});
|