From a8cb010631ef8b13c05f78d5149999326334ceee Mon Sep 17 00:00:00 2001 From: Renoir Boulanger Date: Sat, 17 Jun 2023 20:57:00 -0400 Subject: [PATCH 1/4] feat: Separate concern date formatting outside of component --- js/blogtini.js | 7 ++- js/bt-date-time.js | 80 ++++++++++++++++++++++++ js/context-date-conversion.js | 36 +++++++++++ js/context.js | 28 +++++++++ js/future-imperfect.js | 8 ++- js/init.js | 55 ++++++++++++++++ js/utils-wc.js | 38 +++++++++++ theme.js | 6 +- theme/future-imperfect/bt-post-header.js | 4 +- theme/future-imperfect/bt-post-mini.js | 6 +- 10 files changed, 261 insertions(+), 7 deletions(-) create mode 100644 js/bt-date-time.js create mode 100644 js/context-date-conversion.js create mode 100644 js/context.js create mode 100644 js/init.js create mode 100644 js/utils-wc.js diff --git a/js/blogtini.js b/js/blogtini.js index 5cf031f..a25a6ba 100644 --- a/js/blogtini.js +++ b/js/blogtini.js @@ -166,8 +166,11 @@ import { krsort } from 'https://av.prod.archive.org/js/util/strings.js' // eslint-disable-next-line import/no-named-as-default import search_setup from './future-imperfect.js' import { markdown_to_html, summarize_markdown } from './text.js' +import init from './init.js' +export { init } + // eslint-disable-next-line no-console const log = console.log.bind(console) @@ -700,7 +703,9 @@ async function comments_markup(path) {

${e.name}

${'' /* eslint-disable-next-line no-use-before-define */} - + Reply diff --git a/js/bt-date-time.js b/js/bt-date-time.js new file mode 100644 index 0000000..1661eba --- /dev/null +++ b/js/bt-date-time.js @@ -0,0 +1,80 @@ +import { ContextRequest_DateConversion } from './context-date-conversion.js' +import { ContextRequestEvent } from './context.js' +import { isNotNullOrEmptyString } from './utils-wc.js' + +class DateTimeElement extends HTMLElement { + + static get observedAttributes() { + return ['datetime'] + } + + set datetime(input = '') { + if (isNotNullOrEmptyString(input)) { + const currentValue = this.getAttribute('datetime') + const changed = currentValue !== input + changed && this.setAttribute('datetime', input) + } + } + + constructor() { + super() + const shadowRoot = this.attachShadow({ mode: 'open' }) + const template = shadowRoot.ownerDocument.createElement('template') + template.innerHTML = ` + + + ` + const innerHtml = template.content.cloneNode(true) + shadowRoot.appendChild(innerHtml) + } + + + connectedCallback() { + const datetime = this.getAttribute('datetime') + this.datetime = datetime + if (datetime) { + this.dispatchEvent( + new ContextRequestEvent( + ContextRequest_DateConversion, + this._onContextResponse_DateConversion, + ), + ) + } + } + + _onContextResponse_DateConversion = (data /*: DateConversion */) => { + const { dateIsoString, dateUnix, dateHuman } = data + const timeEl = this.shadowRoot.querySelector('time') + if (dateIsoString) { + timeEl.setAttribute('datetime', dateIsoString) + } + if (dateUnix) { + // timeEl.setAttribute('data-unix-epoch', dateUnix) + timeEl.dataset.unixEpoch = dateUnix + } + if (dateHuman) { + timeEl.textContent = dateHuman + } + } + + attributeChangedCallback(name, oldValue, newValue) { + const changedWithValue = + oldValue !== newValue && isNotNullOrEmptyString(newValue) + if (changedWithValue) { + if (name === 'datetime') { + this.dispatchEvent( + new ContextRequestEvent( + ContextRequest_DateConversion, + this._onContextResponse_DateConversion, + ), + ) + } + } + } +} + +export default DateTimeElement diff --git a/js/context-date-conversion.js b/js/context-date-conversion.js new file mode 100644 index 0000000..c3c391d --- /dev/null +++ b/js/context-date-conversion.js @@ -0,0 +1,36 @@ +// Can use Symbol, but let's not make this more complex. +/** + * Context Request event for signaling we want to get other formats of the same date. + */ +export const ContextRequest_DateConversion = 'date-conversion' + +const KEYS_DateConversion = ['date', 'dateIsoString', 'dateUnix', 'dateHuman'] + +export const isContextRequest_DateConveresion = (event) => + event.context === ContextRequest_DateConversion + +export const assertContextRequest_DateConveresion = (event) => { + if (!isContextRequest_DateConveresion(event)) { + const message = `Unexpected error, we expected a "ContextRequest_DateConversion" context event` + throw new Error(message) + } +} + +export const getFromContext_DateConversion = (event) => { + assertContextRequest_DateConveresion(event) + const maybeDate = event.originalTarget.getAttribute('datetime') + const dateHumanFormat = event.originalTarget.dataset.dateHumanFormat ?? 'MMM D, YYYY' /* data-date-human-format */ + const date = maybeDate ?? null + return Object.freeze({ + date, + dateHumanFormat, + }) +} + +export const isValidContextResponse_DateConversion = (payload) => { + const _keys = Object.keys(payload) + const found = _keys.filter((currentValue) => + KEYS_DateConversion.includes(currentValue), + ) + return found.length === KEYS_DateConversion.length +} diff --git a/js/context.js b/js/context.js new file mode 100644 index 0000000..f56d304 --- /dev/null +++ b/js/context.js @@ -0,0 +1,28 @@ +/** + * An event fired by a context requester to signal it desires a specified context with the given key. + * + * A provider should inspect the `context` property of the event to determine if it has a value that can + * satisfy the request, calling the `callback` with the requested value if so. + * + * If the requested context event contains a truthy `subscribe` value, then a provider can call the callback + * multiple times if the value is changed, if this is the case the provider should pass an `unsubscribe` + * method to the callback which consumers can invoke to indicate they no longer wish to receive these updates. + * + * If no `subscribe` value is present in the event, then the provider can assume that this is a 'one time' + * request for the context and can therefore not track the consumer. + * + * To read more, refer to {@link https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md} + */ +export class ContextRequestEvent extends Event { + /** + * @param context the context key to request + * @param callback the callback that should be invoked when the context with the specified key is available + * @param subscribe an optional argument, if true indicates we want to subscribe to future updates + */ + constructor(context, callback, subscribe) { + super('context-request', { bubbles: true, composed: true }) + this.context = context + this.callback = callback + this.subscribe = subscribe + } +} diff --git a/js/future-imperfect.js b/js/future-imperfect.js index 72528ec..d05359a 100644 --- a/js/future-imperfect.js +++ b/js/future-imperfect.js @@ -91,9 +91,11 @@ function renderSearchResults(results) {

${resultDetails[result.ref].title}

- +

diff --git a/js/init.js b/js/init.js new file mode 100644 index 0000000..446e5ce --- /dev/null +++ b/js/init.js @@ -0,0 +1,55 @@ +import dayjs from 'https://esm.archive.org/dayjs' +import { isValidCustomElement, registerCustomElement } from './utils-wc.js' +import { + getFromContext_DateConversion, + isContextRequest_DateConveresion, + isValidContextResponse_DateConversion, +} from './context-date-conversion.js' + +/** + * Our own components, but not loading them just yet. + */ +const OUR_COMPONENTS = [ + ['bt-time', './bt-date-time.js'], +] + +const handleContextRequest_DateConversion = (event) => { + const test = isContextRequest_DateConveresion(event) + if (isContextRequest_DateConveresion(event)) { + event.stopPropagation() + const { date, dateHumanFormat } = getFromContext_DateConversion(event) + if (typeof date === 'string') { + const data = dayjs(date) + const dateUnix = data.unix() + const dateIsoString = data.toISOString() + const dateHuman = data.format(dateHumanFormat) + const payload = { date, dateIsoString, dateUnix, dateHuman } + isValidContextResponse_DateConversion(payload) && event.callback(payload) + } + } +} + + +const main = async (realm, { components = [] }) => { + // TODO Allow providing our ^ components and utils + + const selectedComponents = [...OUR_COMPONENTS, ...components] + + for (const [name, path] of selectedComponents) { + const imported = await import(path) + const classObj = imported?.default + const isHtmlElement = isValidCustomElement(classObj) + if (isHtmlElement) { + // TODO: detect imported or use ours. + registerCustomElement(realm, name, classObj) + } + } + + // TODO Make this configurable(?) + realm.document.addEventListener('context-request', (event) => { + // ... and others. + handleContextRequest_DateConversion(event) + }) +} + +export default main diff --git a/js/utils-wc.js b/js/utils-wc.js new file mode 100644 index 0000000..2a35553 --- /dev/null +++ b/js/utils-wc.js @@ -0,0 +1,38 @@ +/** + * Normally "boolean" attribute on HTML elements is when + * the attribute is there or not. + * + * @param {*} input + * @returns + */ +export const isNotNullOrEmptyString = (input) => + typeof input === 'string' && input !== '' && input !== 'null' + +// TODO: FIXME +export const isValidCustomElement = (classObj) => { + const prototypeOf = Object.getPrototypeOf(classObj) + return prototypeOf.name === 'HTMLElement' +} + +export const assertIsValidCustomElementName = ( + elementName /*: string */ = '', +) /*: assert is ... */ => { + if (/^[a-z]([\w\d-])+$/.test(elementName) === false) { + const message = `Invalid element name "${elementName}", it must only contain letters and dash.` + throw new Error(message) + } +} + +export const registerCustomElement = ( + { customElements }, + elementName = '', + elementClass = class SomeBogusElement extends Error {}, +) => { + assertIsValidCustomElementName(elementName) + if (!customElements.get(elementName)) { + customElements.define(elementName, elementClass) + } else { + const message = `ERR customElements.define <${elementName} />, already defined.` + throw new Error(message) + } +} diff --git a/theme.js b/theme.js index f8e3b8a..8c775e5 100644 --- a/theme.js +++ b/theme.js @@ -1 +1,5 @@ -import './js/blogtini.js' +import { init } from './js/blogtini.js' + +init(window, { + // Where we'll add customizations +}) \ No newline at end of file diff --git a/theme/future-imperfect/bt-post-header.js b/theme/future-imperfect/bt-post-header.js index 40ef77a..c2d18ab 100644 --- a/theme/future-imperfect/bt-post-header.js +++ b/theme/future-imperfect/bt-post-header.js @@ -28,7 +28,9 @@ customElements.define('bt-post-header', class extends LitElement { ${this.post.type === 'post' ? html`

- + ${PR`

${this.post.author}

` /* chexxx */} ${cfg.reading_time ? html`

${Math.round((212 + this.wordcount(this.post.body_raw)) / 213)}-Minute Read

` : ''}
` : ''} diff --git a/theme/future-imperfect/bt-post-mini.js b/theme/future-imperfect/bt-post-mini.js index e0eb272..af74e21 100644 --- a/theme/future-imperfect/bt-post-mini.js +++ b/theme/future-imperfect/bt-post-mini.js @@ -22,7 +22,11 @@ customElements.define('bt-post-mini', class extends LitElement {

${post.title}

- +
` } From 1faca00b41a5daada660d582118534104d9027f7 Mon Sep 17 00:00:00 2001 From: Renoir Boulanger Date: Sun, 18 Jun 2023 00:04:17 -0400 Subject: [PATCH 2/4] Typo --- js/context-date-conversion.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/js/context-date-conversion.js b/js/context-date-conversion.js index c3c391d..d4e63b9 100644 --- a/js/context-date-conversion.js +++ b/js/context-date-conversion.js @@ -4,20 +4,24 @@ */ export const ContextRequest_DateConversion = 'date-conversion' + const KEYS_DateConversion = ['date', 'dateIsoString', 'dateUnix', 'dateHuman'] + export const isContextRequest_DateConveresion = (event) => event.context === ContextRequest_DateConversion -export const assertContextRequest_DateConveresion = (event) => { + +export const assertContextRequest_DateConversion = (event) => { if (!isContextRequest_DateConveresion(event)) { const message = `Unexpected error, we expected a "ContextRequest_DateConversion" context event` throw new Error(message) } } + export const getFromContext_DateConversion = (event) => { - assertContextRequest_DateConveresion(event) + assertContextRequest_DateConversion(event) const maybeDate = event.originalTarget.getAttribute('datetime') const dateHumanFormat = event.originalTarget.dataset.dateHumanFormat ?? 'MMM D, YYYY' /* data-date-human-format */ const date = maybeDate ?? null @@ -27,10 +31,11 @@ export const getFromContext_DateConversion = (event) => { }) } + export const isValidContextResponse_DateConversion = (payload) => { const _keys = Object.keys(payload) const found = _keys.filter((currentValue) => KEYS_DateConversion.includes(currentValue), ) return found.length === KEYS_DateConversion.length -} +} \ No newline at end of file From 6480188f0f54f20f5edbaca31e1d58b7d8a6216f Mon Sep 17 00:00:00 2001 From: Renoir Boulanger Date: Sun, 18 Jun 2023 00:05:03 -0400 Subject: [PATCH 3/4] feat: Markup parser --- js/bt-un-markup.js | 134 +++++++++++++++++++++++++ js/context-markup.js | 61 +++++++++++ js/defaults.js | 16 +++ js/init.js | 28 +++++- theme/future-imperfect/bt-post-full.js | 7 +- 5 files changed, 241 insertions(+), 5 deletions(-) create mode 100644 js/bt-un-markup.js create mode 100644 js/context-markup.js create mode 100644 js/defaults.js diff --git a/js/bt-un-markup.js b/js/bt-un-markup.js new file mode 100644 index 0000000..1892a5e --- /dev/null +++ b/js/bt-un-markup.js @@ -0,0 +1,134 @@ +import { ContextRequestEvent } from './context.js' +import { ContextRequest_TransformMakup } from './context-markup.js' + + +const SVG_SKELETON_SPINNER = ` + + + + + + ` + +/** + * Take markup language (e.g. Markdown, Rst), and get it transformed into HTML. + */ +class UnMarkupElement extends HTMLElement { + constructor() { + super() + const shadowRoot = this.attachShadow({ mode: 'open' }) + const template = document.createElement('template') + template.innerHTML = ` + +
+
+
+ + ${SVG_SKELETON_SPINNER} + +
+
+
+
+
+            
+          
+
+
+ ` + const innerHtml = template.content.cloneNode(true) + shadowRoot.appendChild(innerHtml) + let slot = this.shadowRoot.querySelector('slot:not([name])') + slot.addEventListener('slotchange', this._onSlotChange) + } + + _applyTransformedMarkup = (html = '') => { + const transformed = html !== '' + const elMarkupViewer = this.shadowRoot.querySelector('#markup-viewer') + const elTransformed = this.shadowRoot.querySelector( + '#markup-transformed', + ) + if (transformed) { + elTransformed.innerHTML = html + elMarkupViewer.classList.remove('is-not-transformed') + elMarkupViewer.classList.add('is-transformed') + } else { + elTransformed.innerHTML = '' + elMarkupViewer.classList.add('is-not-transformed') + elMarkupViewer.classList.remove('is-transformed') + } + } + + /** + * Listen on changes only on default slot, that's the trigger to ask for HTML. + */ + _onSlotChange = (_event /*: HTMLElementEventMap['slotchange'] */) => { + this.dispatchEvent( + new ContextRequestEvent( + ContextRequest_TransformMakup, + this._onContextResponse_UnMarkup, + ), + ) + } + + _onContextResponse_UnMarkup = ({ html = '' }) => { + this._applyTransformedMarkup(html) + } +} + + +export default UnMarkupElement diff --git a/js/context-markup.js b/js/context-markup.js new file mode 100644 index 0000000..b8ec82e --- /dev/null +++ b/js/context-markup.js @@ -0,0 +1,61 @@ +/** + * Context Request event for signaling Markdown needing to be parsed. + */ +export const ContextRequest_TransformMakup = 'transform-markup-context' + + +export const isContextRequest_TransformMakup = (event) => + event.context === ContextRequest_TransformMakup + + +export const assertContextRequest_TransformMakup = (event) => { + if (!isContextRequest_TransformMakup(event)) { + const message = `Unexpected error, we expected a "ContextRequest_TransformMakup" context event` + throw new Error(message) + } +} + + +export const markdownReplaceYouTubeShortCodes = (markdownText) => markdownText.replace( + // replace any youtube shortcodes + /{{<\s*youtube\s+([^\s>}]+)\s*>}}/g, + '', +) + + +export class TransformMarkupSource { + + frontMatterString /*: string */ = '' + + markup /*: string */ = '' + + frontMatter /*?: Record */ = void 0 + + html /*?: string */ = void 0 + + get [Symbol.toStringTag]() { + return 'TransformMarkupSource'; + } + + constructor(source /*: string */ = '') { + const isThreeDashesLine = line => /^---$/.test(line) + const lines = source.split('\n') + // Line numbers of frontMatter. + const [fmBeginLn, fmEndLn] = lines.map((ln, lnNbr) => isThreeDashesLine(ln) ? lnNbr : false).filter(i => i) + // Probably bogus + this.frontMatterString = lines.slice(fmBeginLn + 1, fmEndLn).join('\n') + this.markup = lines.slice(fmEndLn + 1).join('\n') + } +} + + +export const getFromContext_TransformMakup = (event) => { + assertContextRequest_TransformMakup(event) + const innerHTML = event.originalTarget.innerHTML + return new TransformMarkupSource(innerHTML) +} + + +export const isValidContextResponse_TransformMakup = (payload) => { + return /TransformMarkupSource/.test('' + payload) +} \ No newline at end of file diff --git a/js/defaults.js b/js/defaults.js new file mode 100644 index 0000000..6e27e32 --- /dev/null +++ b/js/defaults.js @@ -0,0 +1,16 @@ + +const DEP_SHOWDOWN_VERSION = '2.1.0' +const IMPORT_DEP_SHOWDOWN = `https://esm.archive.org/showdown@${DEP_SHOWDOWN_VERSION}` +// const IMPORT_DEP_SHOWDOWN = `https://ga.jspm.io/npm:showdown@${DEP_SHOWDOWN_VERSION}/dist/showdown.js` + +/** + * By default, markup is in Markdown, but could be in another. + */ +export const markupParser = async (opts = {}) => { + const imported = await import(IMPORT_DEP_SHOWDOWN) + const showdown = imported?.default + const converter = new showdown.Converter({ tables: true, simplifiedAutoLink: true, ...opts }) + converter.setOption('openLinksInNewWindow', true) + + return converter +} \ No newline at end of file diff --git a/js/init.js b/js/init.js index 446e5ce..6ca32dd 100644 --- a/js/init.js +++ b/js/init.js @@ -1,20 +1,29 @@ import dayjs from 'https://esm.archive.org/dayjs' +import yml from 'https://esm.archive.org/js-yaml' import { isValidCustomElement, registerCustomElement } from './utils-wc.js' import { getFromContext_DateConversion, isContextRequest_DateConveresion, isValidContextResponse_DateConversion, } from './context-date-conversion.js' +import { + getFromContext_TransformMakup, + isContextRequest_TransformMakup, + isValidContextResponse_TransformMakup, + markdownReplaceYouTubeShortCodes, +} from './context-markup.js' +import { markupParser } from './defaults.js' /** * Our own components, but not loading them just yet. */ const OUR_COMPONENTS = [ ['bt-time', './bt-date-time.js'], + ['bt-un-markup', './bt-un-markup.js'], ] + const handleContextRequest_DateConversion = (event) => { - const test = isContextRequest_DateConveresion(event) if (isContextRequest_DateConveresion(event)) { event.stopPropagation() const { date, dateHumanFormat } = getFromContext_DateConversion(event) @@ -45,10 +54,27 @@ const main = async (realm, { components = [] }) => { } } + // Make this dynamic, based on configured markup parser + const contentParser = await markupParser() + + const handleContextRequest_TransformMarkup = (event) => { + if (isContextRequest_TransformMakup(event)) { + event.stopPropagation() + const obj = getFromContext_TransformMakup(event) + const { frontMatterString, markup } = obj + const html = contentParser.makeHtml(markdownReplaceYouTubeShortCodes(markup)) + const frontMatter = yml.load(frontMatterString) + Reflect.set(obj, 'html', html) + Reflect.set(obj, 'frontMatter', frontMatter) + isValidContextResponse_TransformMakup(obj) && event.callback(obj) + } + } + // TODO Make this configurable(?) realm.document.addEventListener('context-request', (event) => { // ... and others. handleContextRequest_DateConversion(event) + handleContextRequest_TransformMarkup(event) }) } diff --git a/theme/future-imperfect/bt-post-full.js b/theme/future-imperfect/bt-post-full.js index 0e3f551..ee5ed9c 100644 --- a/theme/future-imperfect/bt-post-full.js +++ b/theme/future-imperfect/bt-post-full.js @@ -21,7 +21,6 @@ customElements.define('bt-post-full', class extends LitElement { render() { const post = url2post(this.url) - const body = markdown_to_html(post.body_raw) const key = new URL(post.url).pathname.replace(/^\/+/, '').replace(/\/+$/, '') // xxx // console.error({key}) @@ -59,9 +58,9 @@ customElements.define('bt-post-full', class extends LitElement { `} -
- ${unsafeHTML(body)} -
+ + ${unsafeHTML(post.body_raw)} + ${post.type === 'post' ? html`