Skip to content

Commit

Permalink
Refreshed API for way more flexibility and added new filter
Browse files Browse the repository at this point in the history
  • Loading branch information
vrugtehagel committed Jul 16, 2024
1 parent 2449d40 commit c9218a4
Show file tree
Hide file tree
Showing 4 changed files with 230 additions and 93 deletions.
4 changes: 2 additions & 2 deletions deno.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"name": "@vrugtehagel/eleventy-document-outline",
"version": "0.0.5",
"version": "0.1.0",
"exports": "./mod.ts",
"publish": {
"include": ["mod.ts", "README.md"]
"include": ["src/**", "mod.ts", "README.md"]
},
"tasks": {
"check": "deno publish --dry-run --allow-dirty"
Expand Down
93 changes: 2 additions & 91 deletions mod.ts
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";
181 changes: 181 additions & 0 deletions src/index.ts
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 });
})
}
45 changes: 45 additions & 0 deletions src/options.ts
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;
};

0 comments on commit c9218a4

Please sign in to comment.