Skip to content

Commit 9ee9971

Browse files
committed
Adds preserve CLI option to preserve elements with CSS classes during markdown conversion.
1 parent 054b0a1 commit 9ee9971

File tree

5 files changed

+148
-30
lines changed

5 files changed

+148
-30
lines changed

cli.js

+9-1
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,15 @@ let { positionals, values } = parseArgs({
6363
type: "string",
6464
default: "",
6565
},
66+
preserve: {
67+
type: "string",
68+
default: "",
69+
}
6670
},
6771
});
6872

6973
let [ type, target ] = positionals;
70-
let { quiet, dryrun, output, help, version, overwrite, cacheduration, format, persist, assetrefs, within } = values;
74+
let { quiet, dryrun, output, help, version, overwrite, cacheduration, format, persist, assetrefs, within, preserve } = values;
7175

7276
if(version) {
7377
const require = createRequire(import.meta.url);
@@ -137,6 +141,10 @@ importer.setDraftsFolder("drafts");
137141
importer.setAssetsFolder("assets");
138142
importer.setAssetReferenceType(assetrefs);
139143

144+
if(preserve) {
145+
importer.addPreserved(preserve);
146+
}
147+
140148
if(persist) {
141149
importer.setPersistTarget(persist);
142150
}

src/Importer.js

+6
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ class Importer {
5454
this.fetcher.setPersistManager(this.persistManager);
5555
}
5656

57+
addPreserved(selectors) {
58+
for(let sel of (selectors || "").split(",")) {
59+
this.markdownService.addPreservedSelector(sel);
60+
}
61+
}
62+
5763
getCounts() {
5864
return {
5965
...this.counts,

src/MarkdownToHtml.js

+97-27
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,55 @@ const WORDPRESS_TO_PRISM_LANGUAGE_TRANSLATION = {
1616
markup: "html",
1717
};
1818

19+
const TAGS_TO_KEEP = [
20+
"abbr",
21+
"address",
22+
"audio",
23+
"cite",
24+
"dd",
25+
"del",
26+
"details",
27+
// "dialog",
28+
"dfn",
29+
// "figure",
30+
"form",
31+
"iframe",
32+
"ins",
33+
"kbd",
34+
"object",
35+
"q",
36+
"sub",
37+
"s",
38+
"samp",
39+
"svg",
40+
"table",
41+
"time",
42+
"var",
43+
"video",
44+
"wbr",
45+
];
46+
1947
class MarkdownToHtml {
2048
#prettierLanguages;
2149
#initStarted;
2250

2351
constructor() {
2452
this.assetsToKeep = new Set();
2553
this.assetsToDelete = new Set();
54+
this.preservedSelectors = new Set();
2655
this.isVerbose = true;
2756
this.counts = {
2857
cleaned: 0
2958
}
3059
}
3160

61+
addPreservedSelector(selector) {
62+
if(!selector.startsWith(".")) {
63+
throw new Error("Invalid preserved selector. Only class names are supported.");
64+
}
65+
this.preservedSelectors.add(selector);
66+
}
67+
3268
async asyncInit() {
3369
if(this.#initStarted) {
3470
return;
@@ -118,6 +154,42 @@ class MarkdownToHtml {
118154
return `\`\`\`${language || ""}\n${content.trim()}\n\`\`\`\n\n`
119155
}
120156

157+
// Supports .className selectors
158+
static hasClass(node, className) {
159+
if(className.startsWith(".")) {
160+
className = className.slice(1);
161+
}
162+
return this.hasAttribute(node, "class", className);
163+
}
164+
165+
static matchAttributeEntry(value, expected) {
166+
// https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors#attrvalue_3
167+
if(expected.startsWith("|=")) {
168+
let actual = expected.slice(2);
169+
// |= is equal to or starts with (and a hyphen)
170+
return value === actual || value.startsWith(`${actual}-`);
171+
}
172+
173+
return value === expected;
174+
}
175+
176+
static hasAttribute(node, attrName, attrValueMatch) {
177+
if(node._attrKeys?.includes(`|${attrName}`)) {
178+
let attrValue = node._attrsByQName?.[attrName]?.data;
179+
// [class] is special, space separated values
180+
if(attrName === "class") {
181+
return attrValue.split(" ").find(entry => {
182+
return this.matchAttributeEntry(entry, attrValueMatch);
183+
});
184+
}
185+
186+
// not [class]
187+
return attrValue === attrValueMatch;
188+
}
189+
190+
return false;
191+
}
192+
121193
getTurndownService(options = {}) {
122194
let { filePath, type } = options;
123195
let isFromWordPress = type === WordPressApi.TYPE || type === HostedWordPressApi.TYPE;
@@ -127,37 +199,35 @@ class MarkdownToHtml {
127199
bulletListMarker: "-",
128200
codeBlockStyle: "fenced",
129201

202+
// Workaround to keep icon elements
203+
blankReplacement(content, node) {
204+
if(node.localName === "i") {
205+
if(MarkdownToHtml.hasClass(node, "|=fa")) {
206+
return node.outerHTML;
207+
}
208+
}
209+
210+
// content will be empty unless it has a preserved child, e.g. <p><i class="fa-"></i></p>
211+
return node.isBlock ? `\n\n${content}\n\n` : content;
212+
},
213+
130214
// Intentionally opt-out
131215
// preformattedCode: true,
132216
});
133217

134-
ts.keep([
135-
"abbr",
136-
"address",
137-
"audio",
138-
"cite",
139-
"dd",
140-
"del",
141-
"details",
142-
// "dialog",
143-
"dfn",
144-
// "figure",
145-
"form",
146-
"iframe",
147-
"ins",
148-
"kbd",
149-
"object",
150-
"q",
151-
"sub",
152-
"s",
153-
"samp",
154-
"svg",
155-
"table",
156-
"time",
157-
"var",
158-
"video",
159-
"wbr",
160-
]);
218+
ts.keep(TAGS_TO_KEEP); // tags run through `keepReplacement` function if match
219+
220+
if(this.preservedSelectors) {
221+
let preserved = Array.from(this.preservedSelectors);
222+
ts.addRule("keep-via-classes", {
223+
filter: function(node) {
224+
return preserved.find(cls => MarkdownToHtml.hasClass(node, cls));
225+
},
226+
replacement: (content, node) => {
227+
return node.outerHTML;
228+
}
229+
});
230+
}
161231

162232
ts.addRule("pre-without-code-to-fenced-codeblock", {
163233
filter: ["pre"],

test/markdown-test.js

+32
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,35 @@ test("Markdown HTML", async (t) => {
2323
assert.equal(await md.toMarkdown(`This is a <ins>test</ins>`, sampleEntry), `This is a <ins>test</ins>`);
2424
assert.equal(await md.toMarkdown(`<table><tbody><tr><td></td></tr></tbody></table>`, sampleEntry), `<table><tbody><tr><td></td></tr></tbody></table>`);
2525
});
26+
27+
test("Keep <i> elements with `fa-` classes", async (t) => {
28+
let md = new MarkdownToHtml();
29+
30+
assert.equal(await md.toMarkdown(`This is an icon <i class="fas fa-sparkles"></i>`, sampleEntry), `This is an icon<i class="fas fa-sparkles"></i>`);
31+
assert.equal(await md.toMarkdown(`This is an icon <i class="fas fa-sparkles"></i>`, sampleEntry), `This is an icon<i class="fas fa-sparkles"></i>`);
32+
assert.equal(await md.toMarkdown(`This is an icon<i class="fas fa-sparkles"></i>`, sampleEntry), `This is an icon<i class="fas fa-sparkles"></i>`);
33+
assert.equal(await md.toMarkdown(`<i class="fas fa-sparkles"></i> This is an icon`, sampleEntry), `<i class="fas fa-sparkles"></i>This is an icon`);
34+
assert.equal(await md.toMarkdown(`<i class="fas fa-sparkles"></i> This is an icon`, sampleEntry), `<i class="fas fa-sparkles"></i>This is an icon`);
35+
assert.equal(await md.toMarkdown(`<i class="fas fa-sparkles"></i>This is an icon`, sampleEntry), `<i class="fas fa-sparkles"></i>This is an icon`);
36+
});
37+
38+
test("Keep <i> elements with `fa-` classes (nested) in an empty parent", async (t) => {
39+
let md = new MarkdownToHtml();
40+
41+
assert.equal(await md.toMarkdown(`<p class="has-text-align-center has-text-color has-link-color has-x-large-font-size wp-elements-007b58a50552546af72f2ebf87b1b426" style="color:#e599f7"><i class="fas fa-sparkles"></i></p>`, sampleEntry), `<i class="fas fa-sparkles"></i>`);
42+
43+
assert.equal(await md.toMarkdown(`<div><p class="has-text-align-center has-text-color has-link-color has-x-large-font-size wp-elements-007b58a50552546af72f2ebf87b1b426" style="color:#e599f7"><i class="fas fa-sparkles"></i></p></div>`, sampleEntry), `<i class="fas fa-sparkles"></i>`);
44+
});
45+
46+
test("If the <i> has content, italics takes precedence", async (t) => {
47+
let md = new MarkdownToHtml();
48+
assert.equal(await md.toMarkdown(`<i class="fas fa-sparkles">Testing</i>`, sampleEntry), `_Testing_`);
49+
});
50+
51+
test("Preserve other classes", async (t) => {
52+
let md = new MarkdownToHtml();
53+
md.addPreservedSelector(".c-button--primary");
54+
55+
assert.equal(await md.toMarkdown(`<a href="https://www.podcastawesome.com/" class="c-button c-button--primary" class="wp-block-fontawesome-blog-icon-button"><i class="fas fa-arrow-right c-button__icon"></i>Listen to the Full Episode!</a>`, sampleEntry), `<a href="https://www.podcastawesome.com/" class="c-button c-button--primary"><i class="fas fa-arrow-right c-button__icon"></i>Listen to the Full Episode!</a>`);
56+
});
57+

test/test.js

+4-2
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ test("WordPress import", async (t) => {
101101
importer.addDataOverride("wordpress", "https://blog.fontawesome.com/wp-json/wp/v2/categories/1", require("./sources/blog-awesome-categories.json"));
102102
importer.addDataOverride("wordpress", "https://blog.fontawesome.com/wp-json/wp/v2/users/155431370", require("./sources/blog-awesome-author.json"));
103103

104+
importer.addPreserved(".c-button--primary");
105+
104106
let entries = await importer.getEntries({ contentType: "markdown" });
105107
assert.equal(entries.length, 1);
106108

@@ -143,9 +145,9 @@ Font Awesome 6 makes it even easier to use icons where you want to. More plugins
143145
144146
We’ll keep fine-tuning that sweet, sweet recipe until February. Believe us; the web’s going to have a new scrumpdillyicious secret ingredient!
145147
146-
[Check Out the Beta!](https://fontawesome.com/v6.0)`);
148+
<a href="https://fontawesome.com/v6.0" class="c-button c-button--primary"><i class="fas fa-arrow-right c-button__icon"></i>Check Out the Beta!</a>`);
147149

148-
assert.equal(post.content.length, 1304);
150+
assert.equal(post.content.length, 1399);
149151
assert.equal(post.authors[0].name, "Matt Johnson");
150152
});
151153

0 commit comments

Comments
 (0)