-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Refreshed API for way more flexibility and added new filter
- Loading branch information
1 parent
2449d40
commit c9218a4
Showing
4 changed files
with
230 additions
and
93 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,91 +1,2 @@ | ||
import * as HTMLParser from "npm:node-html-parser@^6.1"; | ||
|
||
/** Allowed header tag names, lowercased */ | ||
const HEADERS: string[] = ["h1", "h2", "h3", "h4", "h5", "h6", "h7"] as const; | ||
/** Default options, used if not provided explicitly */ | ||
const DEFAULTS: EleventyDocumentOutlineOptions = { | ||
headers: ["h1", "h2", "h3"], | ||
output: ({ id, text, header }) => | ||
`<a href="#${id}" class="link-${header}">${text}</a>`, | ||
}; | ||
|
||
/** The Eleventy config object, should be a better type than "any" but alas */ | ||
type EleventyConfig = any; | ||
/** UUIDs are rendered by the shortcode and then replaced in a transform at build time */ | ||
type UUID = string; | ||
/** Allowed header tag names, lowercased, as a type */ | ||
type LowerCaseHeader = typeof HEADERS[number]; | ||
/** Allowed header tag names, uppercased */ | ||
type UpperCaseHeader = "H1" | "H2" | "H3" | "H4" | "H5" | "H6" | "H7"; | ||
/** Allowed header tag names, case-insensitive */ | ||
type Header = LowerCaseHeader | UpperCaseHeader; | ||
/** The options. All fields are optional through Partial<…> */ | ||
type EleventyDocumentOutlineOptions = { | ||
headers: Header[]; | ||
output: (info: { id: string; text: string; header: Header }) => string; | ||
}; | ||
|
||
/** | ||
* Receives a user-provided header and returns a normalized (lowercased) header. | ||
* Throws if the argument is not a valid header. | ||
*/ | ||
function normalize(header: string): LowerCaseHeader { | ||
const lowerCase = header.toLowerCase(); | ||
if (HEADERS.includes(lowerCase)) return lowerCase; | ||
throw new Error(`Invalid header "${header}" found.`); | ||
} | ||
|
||
/** The plugin itself, with an optional options object as second argument. */ | ||
export default function EleventyDocumentOutline( | ||
config: EleventyConfig, | ||
options: Partial<EleventyDocumentOutlineOptions> = {}, | ||
) { | ||
const normalizedOptions = Object.assign({}, DEFAULTS, options); | ||
const memory = new Map<UUID, LowerCaseHeader[]>(); | ||
|
||
/** The {% outline %} shortcode, with optional configurable headers */ | ||
config.addShortcode("outline", function (...headers: Header[]) { | ||
const uuid = crypto.randomUUID(); | ||
const normalized = headers.length == 0 | ||
? normalizedOptions.headers | ||
: headers.map((header: Header) => normalize(header)); | ||
memory.set(uuid, normalized); | ||
return uuid; | ||
}); | ||
|
||
/** The transform responsible for actually generating and rendering the links */ | ||
config.addTransform( | ||
"document-outline", | ||
function (this: any, content: string) { | ||
const outputPath = this.page.outputPath as string; | ||
if (!outputPath.endsWith(".html")) return content; | ||
|
||
const included = [...memory].filter(([uuid]) => content.includes(uuid)); | ||
const necessaryHeaders = included.flatMap(([_uuid, headers]) => headers); | ||
const uniqueHeaders = [...new Set(necessaryHeaders)]; | ||
const root = HTMLParser.parse(content); | ||
const selector = uniqueHeaders.join(","); | ||
const headerElements = [...root.querySelectorAll(selector)]; | ||
const matches = headerElements.map((element) => { | ||
const id: string = element.id; | ||
if (!id) return null; | ||
if (element.getAttribute("data-outline-ignore") != null) return null; | ||
const text: string = element.rawText; | ||
const header: LowerCaseHeader = element.tagName.toLowerCase(); | ||
const output = normalizedOptions.output({ id, text, header }); | ||
return [header, output]; | ||
}).filter((match) => match != null) as Array<[LowerCaseHeader, string]>; | ||
|
||
let result = content; | ||
for (const [uuid, headers] of included) { | ||
const normalized = headers.map((header) => normalize(header)); | ||
const output = matches | ||
.filter(([header]) => normalized.includes(header)) | ||
.map(([_header, output]) => output) | ||
.join(""); | ||
result = result.replaceAll(uuid, output); | ||
} | ||
return result; | ||
}, | ||
); | ||
} | ||
export { EleventyDocumentOutline as default } from "./src/index.ts"; | ||
export { type EleventyDocumentOutlineOptions } from "./src/options.ts"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,181 @@ | ||
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"; | ||
|
||
/** 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. */ | ||
function RenderPluginForEleventyDocumentOutline(config: EleventyConfig){ | ||
return RenderPlugin(config, { | ||
tagName: null, | ||
tagNameFile: "eleventyDocumentOutlineRender", | ||
accessGlobalData: true, | ||
}) | ||
} | ||
|
||
export function EleventyDocumentOutline( | ||
config: EleventyConfig, | ||
options: EleventyDocumentOutlineOptions = {}, | ||
) { | ||
/** We need this for the shortcode. If it already exists, then adding it | ||
* again is fine and does nothing. */ | ||
config.addPlugin(RenderPluginForEleventyDocumentOutline); | ||
|
||
const { | ||
headers: defaultSelector = "h1,h2,h3", | ||
template: defaultTemplate = { | ||
lang: "liquid", | ||
source: ` | ||
{% for header in headers %} | ||
<a href="#{{ header.id }}" class="link-{{ header.tag }}"> | ||
{{ header.text | escape }} | ||
</a> | ||
{% endfor %} | ||
` | ||
}, | ||
mode: defaultMode = "optin", | ||
slugify = config.getFilter("slugify"), | ||
} = options; | ||
|
||
const memory = new Map<UUID, { | ||
page: EleventyPage; | ||
selector: string; | ||
template: string | { lang: string, source: string }; | ||
mode: "optin" | "dynamic"; | ||
}>(); | ||
|
||
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 }, | ||
selector: string = defaultSelector, | ||
template: false | string | { lang: string, source: string } = false, | ||
mode: "optin" | "dynamic" = defaultMode, | ||
){ | ||
template ||= defaultTemplate; | ||
const uuid = crypto.randomUUID(); | ||
const page = this.page; | ||
memory.set(uuid, { page, selector, template, mode }); | ||
return uuid; | ||
}); | ||
|
||
/** A filter to get access to the underlying generated content | ||
* and an array of headers. This is primarily useful for when you want to | ||
* define a template inline instead of a separate file. Unfortunately, this | ||
* cannot exist as a shortcode, since the entire page needs to render first | ||
* before it can be scanned for headers. Either way, it exists as a filter, | ||
* since then the content to scan is given. For example: | ||
* {% assign outline = content | outline: "h2,h3", "dynamic" %} | ||
* … | ||
* {{ outline.content }} | ||
* … | ||
* {% for header in outline.headers %}…{% endfor %} | ||
* */ | ||
config.addFilter("outline", function( | ||
content: string, | ||
selector: string = defaultSelector, | ||
mode: "optin" | "dynamic" = defaultMode, | ||
): { | ||
content: string, | ||
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 }); | ||
} | ||
return { | ||
content: createdId ? root.toString() : content, | ||
headers | ||
}; | ||
}); | ||
|
||
let tempDir: string; | ||
|
||
/** 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 }, | ||
content: string, | ||
): Promise<string> { | ||
const outputPath = this.page.outputPath as string; | ||
if(!outputPath.endsWith(".html")) return content; | ||
if(![...memory].some(([uuid]) => content.includes(uuid))){ | ||
return content; | ||
} | ||
const root = HTMLParser.parse(content); | ||
const renderFile = config.getShortcode("eleventyDocumentOutlineRender"); | ||
const replacements = new Map<UUID, string>(); | ||
let alteredParsedHTML = false; | ||
for(const [uuid, context] of memory){ | ||
if(!content.includes(uuid)) continue; | ||
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 data = { headers }; | ||
if(typeof template != "string"){ | ||
if(!templateFiles.has(template)){ | ||
if(!tempDir){ | ||
const uuid = crypto.randomUUID(); | ||
tempDir = `tmp-${uuid}`; | ||
await fs.mkdir(tempDir); | ||
} | ||
const fileUUID = crypto.randomUUID(); | ||
const filePath = `${tempDir}/${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); | ||
replacements.set(uuid, rendered); | ||
} | ||
let result = alteredParsedHTML ? root.toString() : content; | ||
for(const [uuid, replacement] of replacements){ | ||
result = result.replace(uuid, replacement); | ||
} | ||
return result; | ||
}); | ||
|
||
config.events.addListener("eleventy.after", async (event: any) => { | ||
await fs.rm(tempDir, { recursive: true, force: true }); | ||
}) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
/** An options object. Every option is optional. They represent the defaults, | ||
* and can be overwritten on a case-by-case basis. */ | ||
export type EleventyDocumentOutlineOptions = { | ||
/** A selector to identify headers with. It defaults to "h1,h2,h3". | ||
* Theoretically it is possible to select elements that are not headers, | ||
* but this is not recommended. */ | ||
headers?: string; | ||
|
||
/** Either a path to an include, to render the nav items with, or an object | ||
* with a `lang` and `source` property to represent such a file. `lang` must | ||
* be a supported templating language, and `source` must be in said language. | ||
* By default, the following is used: | ||
* ```js | ||
* { | ||
* lang: "liquid", | ||
* source: ` | ||
* {% for header in headers %} | ||
* <a | ||
* href="{{ header.text | slugify }}" | ||
* class="link-{{ header.tag | downcase | escape }}" | ||
* >{{ header.text | escape }}</a> | ||
* {% endfor %} | ||
* ` | ||
* } | ||
* ``` | ||
* Whichever is chosen, the template receives one argument; `headers`. This | ||
* is an array of objects representing the individual headers in the | ||
* processed content. Each header has a `.text` property for the original | ||
* text contents and a `tag` property to identify the header's tag name. */ | ||
template?: string | { lang: string, source: string }; | ||
|
||
/** By default, this plugin will ignore headers without an `id` attribute. | ||
* This is so that the document outline is somewhat of an opt-in system, but | ||
* also because it requires modifying and re-rendering HTML to add headers. | ||
* However, you can choose to dynamically create `id` attributes for headers. | ||
* To do so, set this option to `"dynamic"`. */ | ||
mode?: "optin" | "dynamic"; | ||
|
||
/** If the `createIds` option is `true`, then a slugify function is needed | ||
* to transform text in headers to a slug to use for the `id` attribute. By | ||
* default, the built-in `slugify` filter is used. If desired, this can be | ||
* overwritten with a function here. This option is _not_ available on a | ||
* case-by-case basis. */ | ||
slugify?: (input: string) => string; | ||
}; |