From 4fea4d9be582d6e5bf99b758e925f24f5d6fd2cf Mon Sep 17 00:00:00 2001 From: Renoir Boulanger Date: Fri, 17 Feb 2023 15:09:19 -0500 Subject: [PATCH] Suggestions --- js/blogtini.js | 111 ++++++++++++++++++++++++------- js/utils.mjs | 177 +++++++++++++++++++++++++++++++++++++++++++++++++ theme.js | 37 ++++++++++- 3 files changed, 299 insertions(+), 26 deletions(-) create mode 100644 js/utils.mjs diff --git a/js/blogtini.js b/js/blogtini.js index 9833edc..36e33fd 100644 --- a/js/blogtini.js +++ b/js/blogtini.js @@ -151,22 +151,33 @@ jekyll (GitHub Pages) plugins: /* eslint-disable no-continue */ import yml from 'https://esm.archive.org/js-yaml' import dayjs from 'https://esm.archive.org/dayjs' -import showdown from 'https://esm.archive.org/showdown' import hljs from 'https://esm.archive.org/highlightjs' import { krsort } from 'https://av.prod.archive.org/js/util/strings.js' // adds header click actions, etc. // 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' // eslint-disable-next-line no-console const log = console.log.bind(console) +import { + assertBaseUrlWithEndingSlash, + cleanUpInitialPayloadMarkup, + createBlogtiniStuffWrapper, +} from './utils.mjs' +const SITE_ROOT_BASE_URL = new URL(import.meta.url).searchParams.get("SITE_ROOT_BASE_URL") +const PRODUCTION_SITE_ROOT_BASE_URL = new URL(import.meta.url).searchParams.get("PRODUCTION_SITE_ROOT_BASE_URL") +const FILE_SLASH_SLASH_SLASH_SITE_ROOT_BASE_URL = /^file\:\//.test(SITE_ROOT_BASE_URL) +assertBaseUrlWithEndingSlash(PRODUCTION_SITE_ROOT_BASE_URL) +assertBaseUrlWithEndingSlash(SITE_ROOT_BASE_URL) + +const dayJsHelper = dayjs() const state = { + top_dir: SITE_ROOT_BASE_URL, tags: {}, cats: {}, use_github_api_for_files: null, @@ -279,12 +290,28 @@ async function fetcher(url) { async function main() { let tmp - // see if this is an (atypical) "off site" page/post, compared to the main site - // eslint-disable-next-line no-use-before-define - const [my_frontmatter] = markdown_parse(document.getElementsByTagName('body')[0].innerHTML) - const base = my_frontmatter?.base + /** + * Pick all content of the page, let's figure out what to do with it. + * Beware, if we use innerHTML and want the front-matter not to be with escaped entities + * we got to use textContent instead. + */ + const textContent = document.getElementsByTagName('body')[0].textContent + const [raw_fm] = splitFrontMatterAndMarkdown(textContent) + + let my_frontmatter = {} + try { + const loaded = yml.load(raw_fm) + my_frontmatter = loaded + } catch (e) { + console.error('blogtini main ERROR loading YAML', e) + my_frontmatter = {} + } + + const base = my_frontmatter?.base ?? SITE_ROOT_BASE_URL + const href = window.location.href + const maybe = href.replace(base ?? '', '') - state.pathrel = state.is_homepage ? '' : '../' // xxxx generalize + state.pathrel = state.is_homepage ? '' : maybe // xxxx generalize state.top_dir = base ?? state.pathrel state.top_page = state.top_dir.concat(state.filedev ? 'index.html' : '') @@ -314,21 +341,24 @@ async function main() { if (!STORAGE.base) STORAGE.base = base - tmp = yml.load(await fetcher(`${state.top_dir}config.yml`)) - if (tmp) + const configYamlUrl = `${SITE_ROOT_BASE_URL}config.yml` + tmp = yml.load(await fetcher(configYamlUrl)) + if (tmp) { cfg = { ...cfg, ...tmp } // xxx deep merge `sidebar` value hashmap, too + } log({ filter_post, base: STORAGE.base, STORAGE_KEY, cfg, state, }) - const prefix = cfg.repo === 'blogtini' ? state.pathrel : 'https://blogtini.com/' - // eslint-disable-next-line no-use-before-define - add_css(`${prefix}css/blogtini.css`) // xxxx theme.css + add_css(`${SITE_ROOT_BASE_URL}css/blogtini.css`) // xxxx theme.css + cleanUpInitialPayloadMarkup(document) document.getElementsByTagName('body')[0].innerHTML = ` + const blogtiniMain = createBlogtiniStuffWrapper(document, 'blogtini-main') + blogtiniMain.innerHTML = ` ${'' /* eslint-disable-next-line no-use-before-define */} ${site_start()} @@ -343,6 +373,9 @@ cfg.user = 'ajaquith'; cfg.repo = 'securitymetrics'; cfg.branch = 'master' log('xxxx testitos', await find_posts_from_github_api_tree()); return */ + + document.getElementsByTagName('body')[0].prepend(blogtiniMain) + if (!Object.keys(STORAGE).length || STORAGE.created !== dayjs().format('MMM D, YYYY')) // eslint-disable-next-line no-use-before-define await storage_create() @@ -356,7 +389,9 @@ log('xxxx testitos', await find_posts_from_github_api_tree()); return async function storage_create() { // xxx - STORAGE.created = dayjs().format('MMM D, YYYY') + const created = dayJsHelper.format('MMM D, YYYY') + + STORAGE.created = created STORAGE.docs = STORAGE.docs || {} for (const pass of [1, 0]) { @@ -395,10 +430,17 @@ async function storage_create() { // xxx // now make the requests in parallel and wait for them all to answer. const vals = await Promise.all(proms) - const file2markdown = files.reduce((obj, key, idx) => ({ ...obj, [key]: vals[idx] }), {}) + + /** + * documentResponseObjects: They're a collection of HTTP Response objects. + * + * That's where we can get the HTTP Response of files, + * maybe we should keep the response headers and use in storage. + */ + const documentResponseObjects = files.reduce((obj, key, idx) => ({ ...obj, [key]: vals[idx] }), {}) // eslint-disable-next-line no-use-before-define - await parse_posts(file2markdown) + await parse_posts(documentResponseObjects) files = [] proms = [] @@ -439,11 +481,25 @@ function setup_base(urls) { // xxx get more sophisticated than this! eg: if all async function find_posts() { const FILES = [] + const sitemapUrl = `${state.top_dir}sitemap.xml` - const sitemap_urls = (await fetcher(`${state.top_dir}sitemap.xml`))?.split('') + + const sitemap_urls = (await fetcher(sitemapUrl))?.split('') .slice(1) .map((e) => e.split('').slice(0, 1).join('')) .filter((e) => e !== '') + .map((locUrl) => { + let locUrlOut = locUrl + // Fetching content here wouldn't work when is file:/// + if (FILE_SLASH_SLASH_SLASH_SITE_ROOT_BASE_URL === false) { + const rewrittenLocUrl = locUrl.replace( + new RegExp('^' + PRODUCTION_SITE_ROOT_BASE_URL), + SITE_ROOT_BASE_URL, + ) + locUrlOut = rewrittenLocUrl + } + return locUrlOut; + }) state.try_github_api_tree = false state.use_github_api_for_files = false @@ -456,7 +512,7 @@ async function find_posts() { setup_base(sitemap_urls) } else { // handles the "i'm just trying it out" / no sitemap case - FILES.push(location.pathname) // xxx + FILES.push(state.top_dir) // xxx state.sitemap_htm = false } log({ cfg, state }) @@ -549,13 +605,13 @@ function markdown_to_post(markdown, url = location.pathname) { async function parse_posts(markdowns) { - for (const [file, markdown] of Object.entries(markdowns)) { - const url = file.replace(/\.md$/, '') + for (const [resourceUri, response] of Object.entries(responses)) { + const url = resourceUri.replace(/\.md$/, '') // the very first post might have been loaded into text if the webserver served the markdown // file directly. the rest are fetch() results. const post = markdown_to_post( - typeof markdown === 'string' ? markdown : await markdown.text(), + typeof response === 'string' ? response : await response.text(), url, ) if (post) @@ -565,7 +621,6 @@ async function parse_posts(markdowns) { } async function storage_loop() { - showdown.setFlavor('github') // xxx? let htm = '' for (const post of STORAGE.docs) { @@ -611,9 +666,12 @@ async function storage_loop() { // const postxxx = date: post.date.toString().split(' ').slice(0, 4).join(' ') if (filter_post) { - document.getElementsByTagName('body')[0].innerHTML = - // eslint-disable-next-line no-use-before-define - await post_full(post) + const postFullHtml = await post_full(post) + + // TODO: Why always trashing the full body? + document.getElementsByTagName('body')[0].innerHTML = ` + ${postFullHtml} + ` // copy sharing buttons to the fly-out menu document.getElementById('share-menu').insertAdjacentHTML( @@ -1215,7 +1273,10 @@ function finish() { import('./staticman.js') - search_setup(STORAGE.docs, cfg) + import('./future-imperfect.js').then((searchSetup) => { + const { default: search_setup } = searchSetup + search_setup(STORAGE.docs, cfg) + }) } diff --git a/js/utils.mjs b/js/utils.mjs new file mode 100644 index 0000000..afe6676 --- /dev/null +++ b/js/utils.mjs @@ -0,0 +1,177 @@ +/** + * A BaseURL should start by a protocol, and end by a slash. + * Otherwise appending things at the end of it might make unintended invalid paths. + */ +export const isBaseUrlWithEndingSlash = (url) => { + return /^(https?|file)\:.*\/$/.test(url) +} + +export const assertBaseUrlWithEndingSlash = (url) => { + if (isBaseUrlWithEndingSlash(url) === false) { + const message = `Invalid baseURL, it MUST begin by a protocol, and end by a slash, we got: "${url}"` + throw new Error(message) + } +} + +/** + * Check if the begining of the URL provided matches the production. + */ +export const isBaseUrlHostForProduction = ( + siteRootBaseUrl, + compareWith = '', +) => { + assertBaseUrlWithEndingSlash(siteRootBaseUrl) + const regEx = new RegExp('^' + siteRootBaseUrl) + const match = (compareWith ?? '').match(regEx) + return match !== null +} + +/** + * During development, we might still want to read files from current development host + * not production. + */ +export const adjustProductionBaseUrlForDevelopment = ( + input, + productionSiteRootBaseUrl, + siteRootBaseUrl = '', +) => { + console.log('adjustProductionBaseUrlForDevelopment', { + input, + productionSiteRootBaseUrl, + siteRootBaseUrl, + }) + // Question: + // Check if input starts the same as productionSiteRootBaseUrl, + // so we're not scratching our heads why things aren't the same locally + // and once deployed + // assertBaseUrlWithEndingSlash(productionSiteRootBaseUrl) + // assertBaseUrlWithEndingSlash(siteRootBaseUrl) + const replaced = input.replace( + new RegExp('^' + productionSiteRootBaseUrl), + siteRootBaseUrl, + ) + console.log('adjustProductionBaseUrlForDevelopment', { + productionSiteRootBaseUrl, + siteRootBaseUrl, + replaced, + }) + return replaced +} + +export const createBlogtiniEvent = (eventName, detail = {}) => { + const event = new CustomEvent('blogtini', { + bubbles: true, + composed: true, + detail: { eventName, ...detail }, + }) + return event +} + +export const createBlogtiniStuffWrapper = (host, id) => { + const wrapper = host.createElement('div') + wrapper.setAttribute('id', id) + wrapper.setAttribute('class', 'blogtini-stuff') + return wrapper +} + +/** + * Utility for custom elements in getters + */ +export const isNotNullOrStringEmptyOrNull = (input) => + typeof input === 'string' && input !== '' && input !== 'null' + +/** + * Basically just take anything inside the body and put it all + * inside an #original-content + */ +export const cleanUpInitialPayloadMarkup = (host) => { + const wrapper = createBlogtiniStuffWrapper(host, 'blogtini-original-content') + wrapper.setAttribute('style', 'display:none;') + wrapper.append(...host.body.childNodes) + host.body.appendChild(wrapper) + wrapper.querySelectorAll('.blogtini-stuff').forEach((e) => { + host.body.appendChild(e) + }) + host.body.firstChild.dispatchEvent( + createBlogtiniEvent('original-content-moved'), + ) +} + +/** + * Take a string, return an array of two strings. + * + * - front-matter + * - the contents + * + * Beware, in DOM, to get unescaped HTML Entities, it's best to use + * textContent. + */ +export const splitFrontMatterAndMarkdown = (contents) => { + // RBx: Maybe instead pass document here + // const contents = document.getElementsByTagName('body')[0].textContent + // const html = document.getElementsByTagName('body')[0].innerHTML.slice('\n').slice(fenceLineIndexes[1]).join('\n') ?? '' + + // If it starts by a front matter, it's (probably) Markdown + const maybeFrontMatter = (contents ?? '').substring(3, 0) + if (/---/.test(maybeFrontMatter) === false) { + const message = `Invalid document: We do not have a front-matter marker starting exactly at line 0` + throw new Error(message) + } + const lines = (contents ?? '').split('\n') + const firstLines = lines.slice(0, 50) + const fenceLineIndexes = [] + firstLines.forEach((element, idx) => { + const isExactlyThreeDash = /^---$/.test(element) + if (isExactlyThreeDash) { + fenceLineIndexes.push(idx) + } + }) + if (fenceLineIndexes.length !== 2) { + const message = `Invalid document: We do not have a closing front-matter marker within the first 50 lines` + throw new Error(message) + } + if (fenceLineIndexes[0] !== 0) { + const message = `Invalid document: we expect that the front-matter to start at the first line` + throw new Error(message) + } + if (fenceLineIndexes[1] < 1) { + const message = `Invalid document: we expect a front-matter with a few lines` + throw new Error(message) + } + + const frontMatter = lines.slice(1, fenceLineIndexes[1]).join('\n') ?? '' + const rest = lines.slice(fenceLineIndexes[1]).join('\n') ?? '' + + return [frontMatter, rest] +} + +export const getTextFromHtmlizedText = (innerHTML) => { + const node = document.createElement('div') + node.innerHTML = String(innerHTML) + // Let's remove code from contents. + node.querySelectorAll('script,pre,style,code').forEach((what) => { + what.remove() + }) + node.textContent = node.textContent.replace(/\s/g, ' ').trim() + return node +} + +export function debounce(cb, interval, immediate) { + var timeout + + return function () { + var context = this, + args = arguments + var later = function () { + timeout = null + if (!immediate) cb.apply(context, args) + } + + var callNow = immediate && !timeout + + clearTimeout(timeout) + timeout = setTimeout(later, interval) + + if (callNow) cb.apply(context, args) + } +} diff --git a/theme.js b/theme.js index f8e3b8a..25cdfac 100644 --- a/theme.js +++ b/theme.js @@ -1 +1,36 @@ -import './js/blogtini.js' +/** + * Dress up all the things + */ + +/** + * What is the intented URL to expose this site, it's most likely over http, not "file://", + * and possibly not at the root of the domain name. + */ +const PRODUCTION_SITE_ROOT_BASE_URL = 'https://renoirb.github.io/blogtini/' + +/** + * This should work for file:/// URLs + * (assuming it's supported, Safari does, not Chromium thus far 2023-02-03) + */ +const SITE_ROOT_BASE_URL = await import.meta.url?.replace('theme.js', '') // await import.meta.resolve('./theme.js', import.meta.url) // ??'').replace('theme.js', ''); + +document.body.dataset.siteRootBaseUrl = SITE_ROOT_BASE_URL + +scriptElement = document.createElement('script') +scriptElement.setAttribute('type', 'application/json') +scriptElement.setAttribute('id', 'appConfig') +scriptElement.setAttribute('class', 'blogtini-stuff') +const appConfig = { + SITE_ROOT_BASE_URL, + PRODUCTION_SITE_ROOT_BASE_URL, +} +scriptElement.innerText = JSON.stringify(appConfig) +document.body.appendChild(scriptElement) + +import( + `./js/blogtini.js?SITE_ROOT_BASE_URL=${encodeURIComponent( + SITE_ROOT_BASE_URL, + )}&PRODUCTION_SITE_ROOT_BASE_URL=${encodeURIComponent( + PRODUCTION_SITE_ROOT_BASE_URL, + )}` +)