From 28fd9ef2096948804001e043bad726e55492a2b8 Mon Sep 17 00:00:00 2001 From: vrugtehagel Date: Tue, 16 Jul 2024 11:45:36 +0200 Subject: [PATCH] Add outline_parse filter, clean up code --- README.md | 83 ++++++++++++++++++++-------------- deno.json | 5 +++ src/find-headers.ts | 24 ++++++++++ src/index.ts | 100 +++++++++++++++-------------------------- src/render-template.ts | 26 +++++++++++ 5 files changed, 140 insertions(+), 98 deletions(-) create mode 100644 src/find-headers.ts create mode 100644 src/render-template.ts diff --git a/README.md b/README.md index 88710c0..85db54c 100644 --- a/README.md +++ b/README.md @@ -35,47 +35,64 @@ export default function (eleventyConfig) { ``` As shown above, there are additional options one may pass as second argument to -the `.addPlugin()` call, as an object. It may have the following keys: - -- `headers`: an array of headers to include in the document outline. By default, - this is set to `['h1', 'h2', 'h3']`. Note that this can be overwritten on a - case-by-case basis in the `{% outline %}` shortcode. -- `output`: a function receiving a single argument `{ id, text, header }`. The - function must return a snippet of HTML to output. By default, this is - -```js -function output({ id, text, header }) { - return `${text}`; -} -``` - -Which means the options are rendered as sibling anchor tags. The function runs -once per header found, in the same order that they are found in the document. -The `id` then matches the `id` attribute of the given header, the `header` -matches the `.localName` (i.e. lowercased `.tagName`) and the `text` is the text -found inside the header. +the `.addPlugin()` call, as an object. See the `EleventyDocumentOutlineOptions` +type for more information. ## Usage -To output the list of links, use the `{% outline %}` shortcode like so: +This plugin provides three ways of outlining a document. + +### Filter ```liquid - +{{ content | outline }} +{{ content | outline: "h1, h2, h3, h4", "templates/outline.liquid" }} ``` -In this case, only the `h2` and `h3` elements (with `id` attributes) are -included in the outline. If the arguments are left out, as in `{% outline %}`, -then the default headers as configured are used (if not explicityly configured, -only `h1`, `h2` and `h3` elements are included). +First, the `outline` filter. It accepts two (optional) arguments; first, the +selector to find headers with, and second, the template to use, as a file path. +In general, it should be applied to the `content` variable, though it may be +applied to any string of HTML. + +### Shortcode -### Excluding a header +```liquid +{% outline %} +{% outline "h1, h2", "my/outline_template.njk", "dynamic" %} +``` -To exclude a header, add a `data-outline-ignore` attribute to it. +The filter needs a string of HTML to scan for headers. Sometimes, we piece +together a document and need to scan the resulting document for headers. To do +this, there's a shortcode `{% outline %}`. It waits for the whole document to +render, and subsequently substitutes the specified outline afterwards. The +shortcode accepts three (optional) arguments; first, the selector to use to find +headers. Second, a template as a file path, or `false` to use the default +template. Third, the `mode` to run in; either `"optin"` or `"dynamic"`. The +former is the default, and requires you to add `id` attributes to your headers +in order to opt-in to being added to the document outline. The alternative is +`"dynamic"`, which will add `id` attributes to headers dynamically based on the +`slugify` option provided in your config (the default `slugify` filter by +default). The `"dynamic"` mode is slower than `"optin"`; avoid it if you can. +For example, using a markdown plugin to generate the IDs is more efficient. + +### Low-level filter -### Generating header ids +```liquid +{% assign outline = content | outline_parse: "h1, h2, h3", "optin" %} +{{ outline.content }} + +``` -This plugin does not automatically generate `id` attributes for your headers. If -this is something you want or need, use a separate plugin to generate them, such -as e.g. [markdown-it-anchor](https://www.npmjs.com/package/markdown-it-anchor). +It is also possible to parse and scan a piece of HTML, without processing it +through a template. For this, use the `outline_parse` filter. It accepts two +(optional) arguments; a selector, and a mode (either `"optin"` or `"dynamic"`). +It then returns an object with a `content` key and a `headers` key. The former +represents the transformed content, in case `"dynamic"` mode was used and `id` +attributes were added. The `headers` key is an array of objects, each have an +`id`, `text` and `tag` key, all strings. These can be used to generate your own +custom markup in the file itself instead of having to create an template file or +relying on the default configuration. diff --git a/deno.json b/deno.json index 200d8d9..71575e5 100644 --- a/deno.json +++ b/deno.json @@ -7,5 +7,10 @@ }, "tasks": { "check": "deno publish --dry-run --allow-dirty" + }, + "lint": { + "rules": { + "exclude": ["no-explicit-any"] + } } } diff --git a/src/find-headers.ts b/src/find-headers.ts new file mode 100644 index 0000000..ebbb7c0 --- /dev/null +++ b/src/find-headers.ts @@ -0,0 +1,24 @@ +export function findHeaders( + root: any, + selector: string, + mode: "optin" | "dynamic", + slugify: (text: string) => string, +): { + headers: Array<{ id: string; text: string; tag: string }>; + markupChanged: boolean; +} { + const rawHeaders = [...root.querySelectorAll(selector)]; + const headers = []; + let markupChanged = false; + for (const rawHeader of rawHeaders) { + if (!rawHeader.getAttribute("id")) { + if (mode != "dynamic") continue; + markupChanged = true; + } + const text: string = rawHeader.rawText; + const id: string = rawHeader.getAttribute("id") || slugify(text); + const tag: string = rawHeader.tagName.toLowerCase(); + headers.push({ id, text, tag }); + } + return { headers, markupChanged }; +} diff --git a/src/index.ts b/src/index.ts index 5512391..04a3def 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,18 +2,12 @@ import fs from "node:fs/promises"; import { RenderPlugin } from "npm:@11ty/eleventy@^3.0.0-alpha.15"; import * as HTMLParser from "npm:node-html-parser@^6.1"; import type { EleventyDocumentOutlineOptions } from "./options.ts"; +import { findHeaders } from "./find-headers.ts"; +import { renderTemplate } from "./render-template.ts"; /** The Eleventy config object, should be a better type than "any" but alas */ type EleventyConfig = any; -/** The shape of an Eleventy `page` object. */ -type EleventyPage = any; - -/** Unique UUIDs are generated for each use of an outline. We first output - * UUIDs, and once the whole page is rendering, we can process headers and - * replace the UUIDs with content. */ -type UUID = string; - /** We wrap the RenderPlugin with our own function, so Eleventy sees it as a * different plugin. We also rename the shortcodes so that users are not * bothered by the addition of the plugin under the hood. */ @@ -50,19 +44,17 @@ export function EleventyDocumentOutline( tmpDir = "tmpDirEleventyDocumentOutline", } = options; - const memory = new Map(); - const templateFiles = new Map<{ lang: string; source: string }, string>(); - /** Support syntax like: * {% outline "h2,h3", "templates/foo.liquid" %} */ config.addShortcode("outline", function ( - this: { page: EleventyPage }, + this: any, selector: string = defaultSelector, template: false | string | { lang: string; source: string } = false, mode: "optin" | "dynamic" = defaultMode, @@ -86,7 +78,7 @@ export function EleventyDocumentOutline( * … * {% for header in outline.headers %}…{% endfor %} */ - config.addFilter("outline", function ( + config.addFilter("outline_parse", function ( content: string, selector: string = defaultSelector, mode: "optin" | "dynamic" = defaultMode, @@ -95,33 +87,34 @@ export function EleventyDocumentOutline( headers: Array<{ id: string; text: string; tag: string }>; } { const root = HTMLParser.parse(content); - const rawHeaders = [...root.querySelectorAll(selector)]; - const headers = []; - let createdId = false; - for (const rawHeader of rawHeaders) { - if (!rawHeader.getAttribute("id")) { - if (mode != "dynamic") continue; - createdId = true; - } - const text: string = rawHeader.rawText; - const id: string = rawHeader.getAttribute("id") || slugify(text); - const tag: string = rawHeader.tagName.toLowerCase(); - headers.push({ id, text, tag }); - } + const { + headers, + markupChanged, + } = findHeaders(root, selector, mode, slugify); return { - content: createdId ? root.toString() : content, + content: markupChanged ? root.toString() : content, headers, }; }); - let tmpDirCreated = false; + config.addFilter("outline", async function ( + this: any, + content: string, + selector: string = defaultSelector, + template: string | { lang: string; source: string } = defaultTemplate, + ): Promise { + const root = HTMLParser.parse(content); + const { headers } = findHeaders(root, selector, "optin", slugify); + const data = { headers }; + return await renderTemplate.call(this, config, template, tmpDir, data); + }); /** If we have shortcodes, then we process HTML files, find UUIDs inside them * and replace them with the rendered content. If any of them are in * `"dynamic`" mode, then we also add IDs to the headers. For example: * {% outline "h2,h3", "template/foo.liquid", "dynamic" %} */ config.addTransform("document-outline", async function ( - this: { page: EleventyPage }, + this: any, content: string, ): Promise { const outputPath = this.page.outputPath as string; @@ -130,44 +123,21 @@ export function EleventyDocumentOutline( return content; } const root = HTMLParser.parse(content); - const renderFile = config.getShortcode("eleventyDocumentOutlineRender"); - const replacements = new Map(); + const replacements = new Map(); let alteredParsedHTML = false; - for (const [uuid, context] of memory) { - if (!content.includes(uuid)) continue; + await Promise.all([...memory].map(async ([uuid, context]) => { + if (!content.includes(uuid)) return; const { selector, mode, template } = context; - const rawHeaders = [...root.querySelectorAll(selector)]; - const headers = []; - for (const rawHeader of rawHeaders) { - if (!rawHeader.getAttribute("id") && mode != "dynamic") continue; - const text: string = rawHeader.rawText; - if (!rawHeader.getAttribute("id")) { - rawHeader.setAttribute("id", slugify(text)); - alteredParsedHTML = true; - } - const id: string = rawHeader.getAttribute("id") ?? ""; - const tag: string = rawHeader.tagName.toLowerCase(); - headers.push({ text, id, tag }); - } + const { + headers, + markupChanged, + } = findHeaders(root, selector, mode, slugify); const data = { headers }; - if (typeof template != "string") { - if (!templateFiles.has(template)) { - if (!tmpDirCreated) { - await fs.mkdir(tmpDir, { recursive: true }); - tmpDirCreated = true; - } - const fileUUID = crypto.randomUUID(); - const filePath = `${tmpDir}/${fileUUID}.${template.lang}`; - await fs.writeFile(filePath, template.source); - templateFiles.set(template, filePath); - } - } - const path = typeof template == "string" - ? template - : templateFiles.get(template); - const rendered = await renderFile.call(this, path, data); + alteredParsedHTML ||= markupChanged; + const rendered = await renderTemplate + .call(this, config, template, tmpDir, data); replacements.set(uuid, rendered); - } + })); let result = alteredParsedHTML ? root.toString() : content; for (const [uuid, replacement] of replacements) { result = result.replace(uuid, replacement); @@ -175,7 +145,7 @@ export function EleventyDocumentOutline( return result; }); - config.events.addListener("eleventy.after", async (event: any) => { + config.events.addListener("eleventy.after", async () => { await fs.rm(tmpDir, { recursive: true, force: true }); }); } diff --git a/src/render-template.ts b/src/render-template.ts new file mode 100644 index 0000000..c1b75a9 --- /dev/null +++ b/src/render-template.ts @@ -0,0 +1,26 @@ +import fs from "node:fs/promises"; + +const templateMap = new Map<{ lang: string; source: string }, string>(); + +export async function renderTemplate( + this: any, + config: any, + template: string | { lang: string; source: string }, + tmpDir: string, + data: { headers: Array<{ id: string; text: string; tag: string }> }, +): Promise { + const renderFile = config.getShortcode("eleventyDocumentOutlineRender"); + if (typeof template == "string") { + return await renderFile.call(this, template, data); + } + if (!templateMap.has(template)) { + await fs.mkdir(tmpDir, { recursive: true }); + const { lang, source } = template; + const uuid = crypto.randomUUID(); + const filePath = `${tmpDir}/${uuid}.${lang}`; + await fs.writeFile(filePath, source); + templateMap.set(template, filePath); + } + const path = templateMap.get(template); + return await renderFile.call(this, path, data); +}