A simple markdown-to-html web-component

In the last entry, I presented a way to use typescript in basic static html pages with a simple compile task, along with how to use external modules' type definitions while still relying only on their CDNs for the actual code and css loading.

Here, I'm going to add a little my dynamics to the page. I have 2 core features I wanted on the page. First is to be able to write sections of the page in the slightly more readable markdown syntax. Second was to fetch the RSS feeds from my various blogs and render just the first entry in a reasonable layout consistent with the page styles.

While both of these features could be done with a server side language or a full static-site generator, I'll be honest: I'm tired of php, and I find the SSGs to be overkill for something like the simple home landing page I'm making (I also don't like how in general they pollute the file system of the root folder with all of their cruft that could easily be stored in a subfolder to keep things isolated and easier to manage when you restructure a site).

So the basic idea - I have a "card" of the markdown content deployed on the host site with the index page. I want to fetch it, convert it to html, and render it. Yes, this could be done straight in javascript right on the index, but I'm a sucker for some encapsulation. Thus the ideal self-contained solution would be a web-component.

The syntax should be simple - just specify the card content and then some classnames or styles. (Any header/other styling would happen from the beercss library already being used to style the whole page.) A web-component needs a name in a hyphenated form to differentiate it from any current or future standard HTMLElement.<aji-md card="cards/bio.md" class="aji-summary line-clamp-8"/>
So the basic parts. First I'll want the markdown content. This is in cards/bio.md.
###### Joe Shelby
A 50-something year old software engineer living in Northern Virginia, with a lot of geeky interests that occasionally I feel like writing about.
**Below** Are the latest posts from my various blogs.
**To the left** in the drawer are my Social Media links.


The fetch is straightforward,
const card = this.attributes.getNamedItem('card').value;
const res = await fetch(card);
const text = await res.text();


And we'll use showdown as the markdown translator.
const converter = new showdown.Converter();
const html = converter.makeHtml(text);

As before, showdown is something needing to be loaded by both a CDN specified in index.html,
<script src="https://cdn.jsdelivr.net/npm/showdown@2.1.0/dist/showdown.min.js"></script>
as well as installed in the dev repo to get the type definitions. Always a good idea to be sure the versions are in sync, of course.

That's the 2 parts, so turning them into a web component is just adding the wrappers.
/// <reference types="showdown" />
// <aji-md card="cards/bio.md" class="aji-summary line-clamp-8">
export class AjiMd extends HTMLElement {
connectedCallback() {
(async () => {
const card = this.attributes.getNamedItem('card').value;
const res = await fetch(card);
const text = await res.text();
/** @ts-expect-error */
const converter = new showdown.Converter();
const html = converter.makeHtml(text);
this.innerHTML = html;
})();
}
}

customElements.define('aji-md', AjiMd);


Web-components do most of their work in life-cycle methods, similar to the older class components in react. In our case, we only care about doing the one thing when the web-component is connected, so all things happen in connectedCallback.

The top line /// imports the showdown types. Note that there's an annoying 'conflict' between the import syntax, some argument of ejs/esm vs cjs that remains very frustrating. The code runs, the types complain. So the fix is to just put that "expect error" comment that will get the code to just carry on.

Next our web-component is a class that extends HTMLElement. It is possible to extend a specific element where your element would inherent many of the features of the original. However, I'm not sure if it inherits the CSS tag, so if you extended from article, you are an article...but aren't an article tag so the CSS might not match. That's my guess, but I'm just keeping it simple to avoid worrying about that unknown.

This component is not using shadow-dom in any way. The sandboxing of shadow-dom works if all your components are tightly styled, but when you are using a single global styling approach like beer-css, the sandbox stops too much necessary CSS from getting through without having the web-components needing to internally replicate that css. One key example are the use of <i> tags for icons, which remains a pretty common approach for many styling frameworks not in the tailwind family. Admittedly, in this age of custom-components (not every custom-component needs to have web-component javascript) it would be simple to have icons use an <image-icon> custom tag and so avoid the overloading of the classic italics tag. (BeerCSS like most use the the more modern em tag for italics, but this is what we have.

So the card is fetched by grabbing the value of the card attribute of the web-component (there's no need to pre-declare what these attributes can be, though some more advanced libraries can support some validation the way that typescript/prop-types react can do). It is fetched, the content processed by showdown, and then applied to the innerHtml of this, where this is simply the aji-md tag as if you've done const this = document.querySelector('aji-md');.

I've async'ed this in order to keep the promise behavior simple with an await. I personal preference, you're welcome to stick to .then()'s if you prefer.

Finally the web-component is registered to the aji-md tag.

My next web-component is only moderately more complicated, taking an RSS2.0 feed, finding the first entry, and formatting it to my standards for the cards layouts. This is something referred to as an "HTML Web-component", where the overall html of the structure is contained within the component, and thus becomes available to this as being already created before the componentConnected method is called.

The component in use in the html, where feed references this blog. Then it is structured and styled into the title, a subtitle (and both are links to the entry that will get populated by the web-component), one in a 'loading' state. Finally the summary to be populated similar to the innerHTML above.
<aji-latest feed="feeds/jwsdev.net-jottings.xml" class="s12 m6">
<article class="margin">
<p class="inverse-link">
<a href="#" class="aji-feed-title inverse-link" target="_blank">Code Jottings</a>
</p>
<h6 class="link">
<a href="#" class="aji-title link" target="_blank">(Loading...)</a>
</h6>
<p class="aji-summary line-clamp-8"></p>
</article>
</aji-latest>

And the web-component:
const domParser = new DOMParser();

export class AjiLatest extends HTMLElement {
feed = null as string;

applyData(feedUrl: string, feedTitle: string, articleUrl: string, articleTitle: string, articleSummary: string) {
this.querySelector('.aji-feed-title')['href'] = feedUrl || '#';
this.querySelector('.aji-feed-title').textContent = feedTitle;

this.querySelector('.aji-title')['href'] = articleUrl || '#';
this.querySelector('.aji-title').textContent = articleTitle;

this.querySelector('.aji-summary').innerHTML = articleSummary;
}

// connect component
connectedCallback() {
this.feed = this.attributes.getNamedItem('feed').value;
console.log(this.feed);
(async () => {
const res = await fetch(this.feed);
const text = await res.text();
const doc = domParser.parseFromString(text, 'text/xml');
console.log(doc);

// is it rss 2.0?
let titleElement;
titleElement = doc.querySelector('rss[version="2.0"] channel title');
if (titleElement) {
console.log('match rss 2.0');
const title = titleElement.textContent;
const url = doc.querySelector('rss channel link').textContent;

const firstItem = doc.querySelector('rss channel item');
const itemTitle = firstItem.querySelector('title').textContent;
const itemLink = firstItem.querySelector('link').textContent;

// content:encoded - gotta look for only the second part of that and hope there's no overlaps in the item
let itemContent = firstItem.querySelector('encoded')?.textContent;
if (!itemContent) {
itemContent = firstItem.querySelector('description')?.textContent;
}

this.applyData(url, title, itemLink, itemTitle, itemContent);
}
})();
};
}

customElements.define('aji-latest', AjiLatest);

The second part is fetching and parsing the rss. Currently this only supports rss 2.0, but it would be simple to expand to support atom or rss 1. It just uses the built-in DOMParser from the browser. Once it has extracted the values, it applies them, which simply looks for the elements or attributes and populates them. No third party is needed in this example.

For this to work, the RSS feeds need to either be on the same domain (directly or via a proxy), or have a CORS header allowing access by a browser script. Annoyingly most don't have CORS turned on, so instead I have a script in my package.json that will fetch the latest feeds, like "feed:jottings": "curl 'https://jwsdev.net/jottings/index.php?/feeds/index.rss2' > feeds/jwsdev.net-jottings.xml", which I call every time I'm ready to do an update. Yes, it isn't perfectly as dynamic as a proxy or cors solution would be, but it is good enough for my needs, and I could certainly automate it if I really wanted to.

LICENSE: all code examples above are released under the MIT License.

Trackbacks

Trackback specific URI for this entry

Comments

Display comments as Linear | Threaded

No comments

The author does not allow comments to this entry