Skip to content

Commit

Permalink
Add outline_parse filter, clean up code
Browse files Browse the repository at this point in the history
  • Loading branch information
vrugtehagel committed Jul 16, 2024
1 parent 0cf4e00 commit 28fd9ef
Show file tree
Hide file tree
Showing 5 changed files with 140 additions and 98 deletions.
83 changes: 50 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<a href="#${id}" class="link-${header}">${text}</a>`;
}
```

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
<nav id="doc-outline">
{% outline "h2", "h3" %}
</nav>
{{ 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 }}
<nav>
{% for header in outline.headers %}
<a href="#{{ header.id }}">{{ header.text }}</a>
{% endfor %}
</nav>
```

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.
5 changes: 5 additions & 0 deletions deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,10 @@
},
"tasks": {
"check": "deno publish --dry-run --allow-dirty"
},
"lint": {
"rules": {
"exclude": ["no-explicit-any"]
}
}
}
24 changes: 24 additions & 0 deletions src/find-headers.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
100 changes: 35 additions & 65 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -50,19 +44,17 @@ export function EleventyDocumentOutline(
tmpDir = "tmpDirEleventyDocumentOutline",
} = options;

const memory = new Map<UUID, {
page: EleventyPage;
const memory = new Map<string, {
page: any;
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 },
this: any,
selector: string = defaultSelector,
template: false | string | { lang: string; source: string } = false,
mode: "optin" | "dynamic" = defaultMode,
Expand All @@ -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,
Expand All @@ -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<string> {
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<string> {
const outputPath = this.page.outputPath as string;
Expand All @@ -130,52 +123,29 @@ export function EleventyDocumentOutline(
return content;
}
const root = HTMLParser.parse(content);
const renderFile = config.getShortcode("eleventyDocumentOutlineRender");
const replacements = new Map<UUID, string>();
const replacements = new Map<string, string>();
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);
}
return result;
});

config.events.addListener("eleventy.after", async (event: any) => {
config.events.addListener("eleventy.after", async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
}
26 changes: 26 additions & 0 deletions src/render-template.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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);
}

0 comments on commit 28fd9ef

Please sign in to comment.