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,251 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { CollectionItemInput, Field } from "framer-api";
|
|
5
|
+
|
|
6
|
+
import { slugify } from "../libs/framer/filename";
|
|
7
|
+
import {
|
|
8
|
+
createFieldDataEntryInput,
|
|
9
|
+
type FramerCollectionItemExport,
|
|
10
|
+
} from "../libs/framer/framer-client";
|
|
11
|
+
import { parseFrontmatterDocument } from "../libs/framer/frontmatter";
|
|
12
|
+
import {
|
|
13
|
+
markdownToHtml,
|
|
14
|
+
splitMarkdownIntoSections,
|
|
15
|
+
} from "../libs/framer/markdown";
|
|
16
|
+
|
|
17
|
+
import { markdownFaqToJsonLd } from "./json-ld-faq";
|
|
18
|
+
|
|
19
|
+
const SECTION_HTML_FIELD_PATTERN = /^Section\s+(\d+)\s+HTML$/i;
|
|
20
|
+
const SECTION_TITLE_FIELD_PATTERN = /^Section\s+(\d+)\s+Title$/i;
|
|
21
|
+
|
|
22
|
+
export type MarkdownDocument = {
|
|
23
|
+
path: string;
|
|
24
|
+
slug: string;
|
|
25
|
+
metadata: Record<string, unknown>;
|
|
26
|
+
body: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type SectionField = Field & { type: "formattedText" };
|
|
30
|
+
type SectionTitleField = Field;
|
|
31
|
+
|
|
32
|
+
export async function listMarkdownFiles(rootDir: string): Promise<string[]> {
|
|
33
|
+
const entries = await fs.readdir(rootDir, { withFileTypes: true });
|
|
34
|
+
const files = await Promise.all(
|
|
35
|
+
entries.map(async (entry) => {
|
|
36
|
+
const entryPath = path.join(rootDir, entry.name);
|
|
37
|
+
|
|
38
|
+
if (entry.isDirectory()) {
|
|
39
|
+
return listMarkdownFiles(entryPath);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return entry.isFile() && entry.name.endsWith(".md") ? [entryPath] : [];
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
return files.flat().sort((left, right) => left.localeCompare(right));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function readMarkdownDocuments(
|
|
50
|
+
contentDir: string,
|
|
51
|
+
): Promise<MarkdownDocument[]> {
|
|
52
|
+
const files = await listMarkdownFiles(contentDir);
|
|
53
|
+
|
|
54
|
+
return Promise.all(
|
|
55
|
+
files.map(async (filePath) => {
|
|
56
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
57
|
+
const { data, body } = parseFrontmatterDocument(raw);
|
|
58
|
+
const slug =
|
|
59
|
+
typeof data.slug === "string" && data.slug.trim().length > 0
|
|
60
|
+
? data.slug
|
|
61
|
+
: slugify(path.basename(filePath, ".md"));
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
path: filePath,
|
|
65
|
+
slug,
|
|
66
|
+
metadata: data,
|
|
67
|
+
body,
|
|
68
|
+
};
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function resolveSectionHtmlFields(fields: Field[]): SectionField[] {
|
|
74
|
+
return fields
|
|
75
|
+
.filter((field): field is SectionField => {
|
|
76
|
+
return (
|
|
77
|
+
field.type === "formattedText" &&
|
|
78
|
+
SECTION_HTML_FIELD_PATTERN.test(field.name)
|
|
79
|
+
);
|
|
80
|
+
})
|
|
81
|
+
.sort((left, right) => {
|
|
82
|
+
const leftOrder = Number.parseInt(
|
|
83
|
+
left.name.match(SECTION_HTML_FIELD_PATTERN)?.[1] ?? "0",
|
|
84
|
+
10,
|
|
85
|
+
);
|
|
86
|
+
const rightOrder = Number.parseInt(
|
|
87
|
+
right.name.match(SECTION_HTML_FIELD_PATTERN)?.[1] ?? "0",
|
|
88
|
+
10,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
return leftOrder - rightOrder || left.name.localeCompare(right.name);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function resolveSectionTitleFields(
|
|
96
|
+
fields: Field[],
|
|
97
|
+
): SectionTitleField[] {
|
|
98
|
+
return fields
|
|
99
|
+
.filter((field) => SECTION_TITLE_FIELD_PATTERN.test(field.name))
|
|
100
|
+
.sort((left, right) => {
|
|
101
|
+
const leftOrder = Number.parseInt(
|
|
102
|
+
left.name.match(SECTION_TITLE_FIELD_PATTERN)?.[1] ?? "0",
|
|
103
|
+
10,
|
|
104
|
+
);
|
|
105
|
+
const rightOrder = Number.parseInt(
|
|
106
|
+
right.name.match(SECTION_TITLE_FIELD_PATTERN)?.[1] ?? "0",
|
|
107
|
+
10,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
return leftOrder - rightOrder || left.name.localeCompare(right.name);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function extractSectionTitle(markdown: string): string {
|
|
115
|
+
const match = markdown.match(/^#{1,6}\s+(.+?)\s*$/m);
|
|
116
|
+
if (!match) {
|
|
117
|
+
return "";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return match[1].replace(/\s+#+\s*$/, "").trim();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function buildFieldDataInput(
|
|
124
|
+
document: MarkdownDocument,
|
|
125
|
+
fields: Field[],
|
|
126
|
+
config: {
|
|
127
|
+
markdown: string;
|
|
128
|
+
metadata: string[];
|
|
129
|
+
},
|
|
130
|
+
): Promise<CollectionItemInput["fieldData"]> {
|
|
131
|
+
const fieldByName = new Map(fields.map((field) => [field.name, field]));
|
|
132
|
+
const fieldData: NonNullable<CollectionItemInput["fieldData"]> = {};
|
|
133
|
+
|
|
134
|
+
const jsonLd = markdownFaqToJsonLd(document.body);
|
|
135
|
+
if (jsonLd) {
|
|
136
|
+
const field = fieldByName.get("JSON-LD");
|
|
137
|
+
fieldData[field.id] = {
|
|
138
|
+
type: "string",
|
|
139
|
+
value: JSON.stringify(jsonLd),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const metadataFieldName of config.metadata) {
|
|
144
|
+
const field = fieldByName.get(metadataFieldName);
|
|
145
|
+
if (!field) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`Field "${metadataFieldName}" from config was not found in Framer collection.`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const value = document.metadata[metadataFieldName];
|
|
152
|
+
if (value === undefined) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const input = createFieldDataEntryInput(field, value, {
|
|
157
|
+
formattedTextContentType: "markdown",
|
|
158
|
+
});
|
|
159
|
+
if (!input) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`Field "${metadataFieldName}" could not be populated from frontmatter value ${JSON.stringify(value)}.`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
fieldData[field.id] = input;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (config.markdown === "sections") {
|
|
169
|
+
const sectionFields = resolveSectionHtmlFields(fields);
|
|
170
|
+
const titleFields = resolveSectionTitleFields(fields);
|
|
171
|
+
const sections = splitMarkdownIntoSections(document.body);
|
|
172
|
+
|
|
173
|
+
if (sections.length > sectionFields.length) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`Document "${document.path}" has ${sections.length} sections, but Framer collection only exposes ${sectionFields.length} "Section NN HTML" fields.`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
for (const field of sectionFields) {
|
|
180
|
+
fieldData[field.id] = {
|
|
181
|
+
type: field.type,
|
|
182
|
+
value: "",
|
|
183
|
+
contentType: "html",
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
for (const field of titleFields) {
|
|
188
|
+
const input = createFieldDataEntryInput(field, "");
|
|
189
|
+
if (input) {
|
|
190
|
+
fieldData[field.id] = input;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
for (const [index, section] of sections.entries()) {
|
|
195
|
+
const field = sectionFields[index];
|
|
196
|
+
const titleField = titleFields[index];
|
|
197
|
+
const html = await markdownToHtml(section);
|
|
198
|
+
const title = extractSectionTitle(section);
|
|
199
|
+
fieldData[field.id] = {
|
|
200
|
+
type: field.type,
|
|
201
|
+
value: html,
|
|
202
|
+
contentType: "html",
|
|
203
|
+
};
|
|
204
|
+
if (titleField) {
|
|
205
|
+
const input = createFieldDataEntryInput(titleField, title);
|
|
206
|
+
if (input) {
|
|
207
|
+
fieldData[titleField.id] = input;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return fieldData;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const markdownField = fieldByName.get(config.markdown);
|
|
216
|
+
if (!markdownField) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
`Markdown field "${config.markdown}" from config was not found in Framer collection.`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const bodyInput = createFieldDataEntryInput(markdownField, document.body, {
|
|
223
|
+
formattedTextContentType: "markdown",
|
|
224
|
+
});
|
|
225
|
+
if (!bodyInput) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
`Markdown field "${config.markdown}" could not accept the document body.`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
fieldData[markdownField.id] = bodyInput;
|
|
231
|
+
|
|
232
|
+
return fieldData;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export async function buildCollectionItemInput(
|
|
236
|
+
document: MarkdownDocument,
|
|
237
|
+
fields: Field[],
|
|
238
|
+
config: {
|
|
239
|
+
markdown: string;
|
|
240
|
+
metadata: string[];
|
|
241
|
+
},
|
|
242
|
+
existingItemsBySlug: Map<string, FramerCollectionItemExport>,
|
|
243
|
+
): Promise<CollectionItemInput> {
|
|
244
|
+
const existing = existingItemsBySlug.get(document.slug);
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
id: existing?.id,
|
|
248
|
+
slug: document.slug,
|
|
249
|
+
fieldData: await buildFieldDataInput(document, fields, config),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
|
|
4
|
+
import yargs from "yargs";
|
|
5
|
+
import { hideBin } from "yargs/helpers";
|
|
6
|
+
|
|
7
|
+
import { loadConfig } from "../libs/framer/config";
|
|
8
|
+
import {
|
|
9
|
+
FramerClient,
|
|
10
|
+
type FramerCollectionItemExport,
|
|
11
|
+
} from "../libs/framer/framer-client";
|
|
12
|
+
|
|
13
|
+
import { buildCollectionItemInput, readMarkdownDocuments } from "./import";
|
|
14
|
+
|
|
15
|
+
export type CliArgs = {
|
|
16
|
+
token?: string;
|
|
17
|
+
config: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export async function runCli(argv = hideBin(process.argv)): Promise<void> {
|
|
21
|
+
const args = await yargs(argv)
|
|
22
|
+
.scriptName("markdown-to-framer")
|
|
23
|
+
.usage("$0 --token <token> --config config/use-cases.yml")
|
|
24
|
+
.option("token", {
|
|
25
|
+
type: "string",
|
|
26
|
+
describe: "Framer API token. Falls back to FRAMER_TOKEN.",
|
|
27
|
+
})
|
|
28
|
+
.option("config", {
|
|
29
|
+
type: "string",
|
|
30
|
+
demandOption: true,
|
|
31
|
+
describe: "Path to a single sync config file",
|
|
32
|
+
})
|
|
33
|
+
.strict()
|
|
34
|
+
.help()
|
|
35
|
+
.parse();
|
|
36
|
+
|
|
37
|
+
const cliArgs = args as unknown as CliArgs;
|
|
38
|
+
const token = cliArgs.token ?? process.env.FRAMER_TOKEN;
|
|
39
|
+
if (!token) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
"Framer token is required. Pass --token or set FRAMER_TOKEN.",
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const { config } = await loadConfig(cliArgs.config);
|
|
46
|
+
const contentDir = path.resolve(
|
|
47
|
+
config.content ?? path.join("content", config.collection),
|
|
48
|
+
);
|
|
49
|
+
const documents = await readMarkdownDocuments(contentDir);
|
|
50
|
+
const client = new FramerClient({
|
|
51
|
+
projectRef: config.site,
|
|
52
|
+
token,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const { collection, fields, items } = await client.getCollectionExportData(
|
|
57
|
+
config.collection,
|
|
58
|
+
);
|
|
59
|
+
const existingItemsBySlug = new Map(
|
|
60
|
+
items.map((item: FramerCollectionItemExport) => [item.slug, item]),
|
|
61
|
+
);
|
|
62
|
+
const upserts = [];
|
|
63
|
+
|
|
64
|
+
for (const document of documents) {
|
|
65
|
+
upserts.push(
|
|
66
|
+
await buildCollectionItemInput(
|
|
67
|
+
document,
|
|
68
|
+
fields,
|
|
69
|
+
config,
|
|
70
|
+
existingItemsBySlug,
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
await client.upsertCollectionItems(collection, upserts);
|
|
76
|
+
|
|
77
|
+
for (const document of documents) {
|
|
78
|
+
console.log(document.slug);
|
|
79
|
+
}
|
|
80
|
+
} finally {
|
|
81
|
+
await client.disconnect();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
type FaqItem = {
|
|
2
|
+
question: string;
|
|
3
|
+
answer: string;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export interface JsonLd {
|
|
7
|
+
"@context": string;
|
|
8
|
+
"@type": string;
|
|
9
|
+
mainEntity: {
|
|
10
|
+
"@type": string;
|
|
11
|
+
name: string;
|
|
12
|
+
acceptedAnswer: {
|
|
13
|
+
"@type": string;
|
|
14
|
+
text: string;
|
|
15
|
+
};
|
|
16
|
+
}[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parseHeading(line: string): { level: number; text: string } | null {
|
|
20
|
+
const match = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
|
|
21
|
+
|
|
22
|
+
if (!match) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
level: match[1].length,
|
|
28
|
+
text: match[2].trim(),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeMarkdownText(markdown: string): string {
|
|
33
|
+
return markdown
|
|
34
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
35
|
+
.replace(/[*_`>#-]/g, "")
|
|
36
|
+
.replace(/\s+/g, " ")
|
|
37
|
+
.trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function findFaqHeading(
|
|
41
|
+
lines: string[],
|
|
42
|
+
): { index: number; level: number } | null {
|
|
43
|
+
for (let i = 0; i < lines.length; i++) {
|
|
44
|
+
const heading = parseHeading(lines[i]);
|
|
45
|
+
|
|
46
|
+
if (heading && heading.text.trim().toLowerCase() === "faq") {
|
|
47
|
+
return { index: i, level: heading.level };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function collectSection(
|
|
55
|
+
lines: string[],
|
|
56
|
+
startIndex: number,
|
|
57
|
+
parentLevel: number,
|
|
58
|
+
): string[] {
|
|
59
|
+
const section: string[] = [];
|
|
60
|
+
|
|
61
|
+
for (let i = startIndex; i < lines.length; i++) {
|
|
62
|
+
const heading = parseHeading(lines[i]);
|
|
63
|
+
|
|
64
|
+
if (heading && heading.level <= parentLevel) {
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
section.push(lines[i]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return section;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function extractQuestions(lines: string[], faqLevel: number): FaqItem[] {
|
|
75
|
+
const items: FaqItem[] = [];
|
|
76
|
+
|
|
77
|
+
let currentQuestion: string | null = null;
|
|
78
|
+
let currentAnswerLines: string[] = [];
|
|
79
|
+
|
|
80
|
+
const flush = () => {
|
|
81
|
+
if (!currentQuestion) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const answer = normalizeMarkdownText(currentAnswerLines.join("\n"));
|
|
86
|
+
|
|
87
|
+
if (answer.length > 0) {
|
|
88
|
+
items.push({
|
|
89
|
+
question: currentQuestion,
|
|
90
|
+
answer,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
for (const line of lines) {
|
|
96
|
+
const heading = parseHeading(line);
|
|
97
|
+
|
|
98
|
+
if (heading && heading.level > faqLevel) {
|
|
99
|
+
flush();
|
|
100
|
+
currentQuestion = heading.text;
|
|
101
|
+
currentAnswerLines = [];
|
|
102
|
+
} else if (currentQuestion) {
|
|
103
|
+
currentAnswerLines.push(line);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
flush();
|
|
108
|
+
|
|
109
|
+
return items;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function extractFaqItems(markdown: string): FaqItem[] {
|
|
113
|
+
const lines = markdown.split(/\r?\n/);
|
|
114
|
+
|
|
115
|
+
const faqStart = findFaqHeading(lines);
|
|
116
|
+
if (!faqStart) {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const { index: faqIndex, level: faqLevel } = faqStart;
|
|
121
|
+
const faqLines = collectSection(lines, faqIndex + 1, faqLevel);
|
|
122
|
+
|
|
123
|
+
return extractQuestions(faqLines, faqLevel);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function markdownFaqToJsonLd(markdown: string): JsonLd | null {
|
|
127
|
+
const items = extractFaqItems(markdown);
|
|
128
|
+
if (items.length === 0) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const jsonLd: JsonLd = {
|
|
133
|
+
"@context": "https://schema.org",
|
|
134
|
+
"@type": "FAQPage",
|
|
135
|
+
mainEntity: items.map((item) => ({
|
|
136
|
+
"@type": "Question",
|
|
137
|
+
name: item.question,
|
|
138
|
+
acceptedAnswer: {
|
|
139
|
+
"@type": "Answer",
|
|
140
|
+
text: item.answer,
|
|
141
|
+
},
|
|
142
|
+
})),
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return jsonLd;
|
|
146
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": ".",
|
|
8
|
+
"lib": ["DOM", "ES2020"],
|
|
9
|
+
"jsx": "preserve",
|
|
10
|
+
"allowJs": true,
|
|
11
|
+
"resolveJsonModule": true,
|
|
12
|
+
"esModuleInterop": true,
|
|
13
|
+
"allowSyntheticDefaultImports": true,
|
|
14
|
+
"types": ["node", "jest"],
|
|
15
|
+
"skipLibCheck": true,
|
|
16
|
+
"ignoreDeprecations": "6.0",
|
|
17
|
+
"strict": false
|
|
18
|
+
},
|
|
19
|
+
"include": ["src/**/*", "test/**/*"]
|
|
20
|
+
}
|