@staticbolt/docs 0.0.1-beta.2 → 0.0.1-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,7 +33,7 @@ export default defineConfig({
33
33
  }),
34
34
 
35
35
  plugins.htmlLayoutPlugin({ tags: ["layout", "part", "icon"] }),
36
- theme.HtmlCodeBlockPlugin({ rehypeExpressiveCodeOptions }),
36
+ theme.htmlCodeBlockPlugin({ rehypeExpressiveCodeOptions }),
37
37
  theme.katexFontsPlugin(),
38
38
  theme.pagefindPlugin(),
39
39
  theme.llmsPlugin({ siteUrl: "https://example.com" }),
@@ -96,6 +96,7 @@ Copy this as `sources/layouts/document.layout.html` and edit the names:
96
96
  src="@staticbolt/docs/parts/document/document.part.html"
97
97
  title="My Project"
98
98
  >
99
+ <slot name="sidebar" slot="sidebar"></slot>
99
100
  <part slot="icons" src="@parts/social-icons/social-icons.part.html"></part>
100
101
  <slot />
101
102
  </part>
@@ -107,17 +108,17 @@ Copy this as `sources/layouts/document.layout.html` and edit the names:
107
108
 
108
109
  To change the page structure, copy `node_modules/@staticbolt/docs/sources/parts/document/document.part.html` into the project and point the layout at the copy; it references the other parts by package path, so it works as is.
109
110
 
110
- | Part | Role |
111
- | --------------------- | -------------------------------------------------------------------------------------------- |
112
- | `document` | The whole page. `title`, `minHeading`, `maxHeading`, `mermaid`, slot `icons`, default slot. |
113
- | `header` | Takes `title`. Slot `icons` for the social links. |
114
- | `sidebar` | Filled by `contentPlugin`. Slot `icons` for the social links. |
115
- | `table-of-content` | `-desktop` takes `minHeading` and `maxHeading`, `-mobile` none. |
116
- | `pagination-links` | Previous/next links read from the sidebar. |
117
- | `callouts` | Build-time script turning collapsible callouts into accordions. |
118
- | `mermaid` | Renders ` ```mermaid ` fences. Removes itself from pages without. Needs `mermaid` installed. |
119
- | `color-scheme-switch` | Already inside header and sidebar. |
120
- | `badge` | Used by the sidebar for `data.badgeText` / `data.badgeVariant`. |
111
+ | Part | Role |
112
+ | --------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
113
+ | `document` | The whole page. `title`, `minHeading`, `maxHeading`, `mermaid`, slots `sidebar` and `icons`, default slot. |
114
+ | `header` | Takes `title`. Slot `icons` for the social links. |
115
+ | `sidebar` | Default slot for the tree, filled by `contentPlugin` through the layout's `sidebar` slot. Slot `icons` for the social links. |
116
+ | `table-of-content` | `-desktop` takes `minHeading` and `maxHeading`, `-mobile` none. |
117
+ | `pagination-links` | Previous/next links read from the sidebar. |
118
+ | `callouts` | Build-time script turning collapsible callouts into accordions. |
119
+ | `mermaid` | Renders ` ```mermaid ` fences. Removes itself from pages without. Needs `mermaid` installed. |
120
+ | `color-scheme-switch` | Already inside header and sidebar. |
121
+ | `badge` | Used by the sidebar for `data.badgeText` / `data.badgeVariant`. |
121
122
 
122
123
  ## Styles
123
124
 
@@ -145,4 +146,5 @@ Any `--sb-*` variable from `global.css` can be overridden the same way.
145
146
  ## Overriding a part
146
147
 
147
148
  Copy it from `node_modules/@staticbolt/docs/sources/parts/` into the project and point the layout at the copy.
148
- For the sidebar parts pass the copy through `contentPlugin`'s `sidebarLayout`, `directoryLayout`, or `linkLayout`.
149
+ For the folder and link parts pass the copy through `contentPlugin`'s `directoryLayout` or `linkLayout`.
150
+ A layout of your own has to forward the `sidebar` slot down to the sidebar part, as the one above does.
package/lib/index.d.mts CHANGED
@@ -5,7 +5,6 @@ import { Root } from "hast";
5
5
  import { LanguageRegistration } from "shiki";
6
6
  import { Plugin as Plugin$1 } from "unified";
7
7
  import { Root as Root$1 } from "mdast";
8
- import { Node } from "unist";
9
8
  //#region plugins/code-block-plugin.d.ts
10
9
  interface HtmlCodeBlockPlugin {
11
10
  /**
@@ -21,7 +20,7 @@ interface HtmlCodeBlockPlugin {
21
20
  * replaces the tag with a syntax highlighted code block. Use the `lang` attribute to set the language, and the other attributes
22
21
  * for the rest of the expressive-code metadata.
23
22
  */
24
- declare function HtmlCodeBlockPlugin(options?: HtmlCodeBlockPlugin): Plugin;
23
+ declare function htmlCodeBlockPlugin(options?: HtmlCodeBlockPlugin): Plugin;
25
24
  //#endregion
26
25
  //#region plugins/collapsible-plugin.d.ts
27
26
  interface CollapsiblePluginOptions {
@@ -161,38 +160,15 @@ interface ContentPluginOptions {
161
160
  * @default true
162
161
  */
163
162
  collapsed?: boolean;
164
- /** Defines the layout name/path to be used for markdown files. */
163
+ /**
164
+ * Defines the layout name/path to be used for markdown files.\
165
+ * The sidebar tree goes into the layout's `sidebar` slot, so every layout listed here has to carry a `<slot name="sidebar">`,
166
+ * or forward one to the part that does.
167
+ */
165
168
  layouts: {
166
169
  default: string;
167
170
  [key: string]: string;
168
171
  };
169
- /** The layout into which the generated sidebar HTML will be injected. */
170
- sidebarLayout?: {
171
- /**
172
- * Path to the layout file.
173
- *
174
- * @default the theme's `sidebar.part.html`
175
- */
176
- path?: string;
177
- /**
178
- * The custom-element tag name used for this layout.
179
- *
180
- * If omitted, the tag name will be auto extracted from the layout path
181
- */
182
- tag?: string;
183
- /**
184
- * The attribute on the layout tag that holds the source path.
185
- *
186
- * @default "src"
187
- */
188
- sourceAttribute?: string;
189
- /**
190
- * The slot name into which the sidebar is inserted.
191
- *
192
- * @default "sidebar"
193
- */
194
- slotName?: string;
195
- };
196
172
  /** The layout used to render a collapsible directory node. */
197
173
  directoryLayout?: {
198
174
  /**
@@ -293,6 +269,25 @@ interface LlmsPluginOptions {
293
269
  title?: string;
294
270
  /** One-line summary quoted under the heading. */
295
271
  summary?: string;
272
+ /**
273
+ * Glob patterns for the pages to list.
274
+ *
275
+ * @default ["pages/**\/*.md"]
276
+ */
277
+ include?: string[];
278
+ /**
279
+ * Glob patterns to leave out.
280
+ *
281
+ * @default [ ]
282
+ */
283
+ exclude?: string[];
284
+ /**
285
+ * Path of the pair of files, relative to the output directory and without the extension: `"llms"` writes `llms.txt` and
286
+ * `llms-full.txt`. Give each plugin a path of its own when a site runs more than one.
287
+ *
288
+ * @default "llms"
289
+ */
290
+ output?: string;
296
291
  }
297
292
  /**
298
293
  * Writes `llms.txt` and `llms-full.txt`, plus a copy of every page's markdown beside its HTML.
@@ -381,6 +376,6 @@ interface PagefindPluginOptions {
381
376
  declare function pagefindPlugin({ glob }?: PagefindPluginOptions): Plugin;
382
377
  //#endregion
383
378
  //#region plugins/wrap-table.d.ts
384
- declare function wrapTables(): (tree: Node) => void;
379
+ declare function wrapTables(): (tree: Root) => void;
385
380
  //#endregion
386
- export { CollapsiblePluginOptions, HtmlCodeBlockPlugin, PersistentCache, RemarkObsidianCommentsOptions, cachedRehypeExpressiveCode, contentPlugin, ecCachePlugin, ecCacheSalt, htmlWithTypeScriptScripts, katexFontsPlugin, llmsPlugin, normalizeTitle, pagefindPlugin, pluginCollapsible, rehypeMermaidFence, remarkObsidianComments, remarkObsidianTags, sha1, wrapTables };
381
+ export { CollapsiblePluginOptions, HtmlCodeBlockPlugin, PersistentCache, RemarkObsidianCommentsOptions, cachedRehypeExpressiveCode, contentPlugin, ecCachePlugin, ecCacheSalt, htmlCodeBlockPlugin, htmlWithTypeScriptScripts, katexFontsPlugin, llmsPlugin, normalizeTitle, pagefindPlugin, pluginCollapsible, rehypeMermaidFence, remarkObsidianComments, remarkObsidianTags, sha1, wrapTables };
package/lib/plugins.mjs CHANGED
@@ -5,13 +5,12 @@ import { ExpressiveCodeBlock } from "expressive-code";
5
5
  import { toHtml } from "hast-util-to-html";
6
6
  import rehypeExpressiveCode, { createRenderer } from "rehype-expressive-code";
7
7
  import { createHash } from "node:crypto";
8
- import { cpSync, existsSync, globSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
8
+ import { cpSync, existsSync, globSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { dirname, join } from "node:path";
10
10
  import yaml from "yaml";
11
11
  import { mkdir, readFile, writeFile } from "node:fs/promises";
12
12
  import { visit } from "unist-util-visit";
13
13
  import * as pagefind from "pagefind";
14
- import { selectAll } from "hast-util-select";
15
14
  //#region plugins/ec-cache.ts
16
15
  /**
17
16
  * Persistent cross-build caches for expressive-code rendering.
@@ -119,7 +118,7 @@ function cachedRehypeExpressiveCode(options) {
119
118
  * replaces the tag with a syntax highlighted code block. Use the `lang` attribute to set the language, and the other attributes
120
119
  * for the rest of the expressive-code metadata.
121
120
  */
122
- function HtmlCodeBlockPlugin(options = {}) {
121
+ function htmlCodeBlockPlugin(options = {}) {
123
122
  const tag = options.tagName ?? "code-block";
124
123
  let renderCache;
125
124
  let diskCache;
@@ -417,11 +416,6 @@ function prepareOptions(options) {
417
416
  const icons = options.icons ?? "";
418
417
  const publish = options.publish ?? true;
419
418
  const collapsed = options.collapsed ?? true;
420
- const sidebarLayout = options.sidebarLayout ?? {};
421
- sidebarLayout.path ??= themeSidebarPart("sidebar.part.html");
422
- sidebarLayout.tag ??= path.basename(sidebarLayout.path).split(".")[1];
423
- sidebarLayout.sourceAttribute ??= "src";
424
- sidebarLayout.slotName ??= "sidebar";
425
419
  const directoryLayout = options.directoryLayout ?? {};
426
420
  directoryLayout.path ??= themeSidebarPart("directory.part.html");
427
421
  directoryLayout.tag ??= path.basename(directoryLayout.path).split(".")[1];
@@ -439,7 +433,6 @@ function prepareOptions(options) {
439
433
  icons,
440
434
  publish,
441
435
  collapsed,
442
- sidebarLayout,
443
436
  directoryLayout,
444
437
  linkLayout
445
438
  };
@@ -500,51 +493,6 @@ function parseSidebarFrontmatter(file) {
500
493
  };
501
494
  }
502
495
  //#endregion
503
- //#region plugins/content-plugin/render-tree.ts
504
- /** `<icon>` source for a node, `undefined` when the project has no icons directory or the node no icon. */
505
- function iconSource(icons, icon) {
506
- if (!icons || !icon) return;
507
- return `${icons.replace(/\/$/, "")}/${icon}.icon.html`;
508
- }
509
- function renderDirectory(directory, items, { directoryLayout, customizations, icons }) {
510
- const customization = customizations[directory.realPath];
511
- const data = {
512
- title: customization?.label ?? directory.title,
513
- collapsed: customization?.collapsed ?? directory.collapsed,
514
- ...customization?.data,
515
- iconSrc: iconSource(icons, customization?.data?.icon)
516
- };
517
- return `<li class="sidebar-list-item">
518
- <${directoryLayout.tag} dataJson='${escapeAttributeValue(JSON.stringify(data))}' ${directoryLayout.sourceAttribute}="${directoryLayout.path}">
519
- <ul class="sidebar-list">${items}</ul>
520
- </${directoryLayout.tag}>
521
- </li>`;
522
- }
523
- function renderLink(link, { linkLayout, customizations, icons }) {
524
- const customization = customizations[link.realPath];
525
- const data = {
526
- title: customization?.label ?? link.title,
527
- fullTitle: link.pageTitle ?? link.title,
528
- href: encodeURIComponent(link.filePath),
529
- ...customization?.data,
530
- iconSrc: iconSource(icons, customization?.data?.icon)
531
- };
532
- return `<li class="sidebar-list-item">
533
- <${linkLayout.tag} dataJson='${escapeAttributeValue(JSON.stringify(data))}' ${linkLayout.sourceAttribute}="${linkLayout.path}"></${linkLayout.tag}>
534
- </li>`;
535
- }
536
- function renderTree(items, options) {
537
- let tree = "";
538
- for (const item of items) {
539
- if (isItem(item)) {
540
- tree += renderLink(item, options);
541
- continue;
542
- }
543
- tree += renderDirectory(item, renderTree(item.items, options), options);
544
- }
545
- return tree;
546
- }
547
- //#endregion
548
496
  //#region plugins/content-plugin/generate-tree.ts
549
497
  function getOrCreateDirectory(tree, title, realPath, collapsed) {
550
498
  let directory = tree.find((entry) => "items" in entry && entry.title === title);
@@ -610,7 +558,7 @@ function generateTree(options) {
610
558
  const fileNameWithoutExtension = path.parse(fileName).name;
611
559
  const title = fileNameWithoutExtension === "index" && directories.length === 0 ? "Home" : normalizeTitle(fileNameWithoutExtension);
612
560
  const pathAsLink = fileNameWithoutExtension === "index" ? path.dirname(file) : path.join(path.dirname(file), fileNameWithoutExtension);
613
- const href = path.relative(path.dirname(options.sidebarLayout.path), pathAsLink) + "/";
561
+ const href = this.resolver.aliasPath(pathAsLink) + "/";
614
562
  currentTree.push({
615
563
  title,
616
564
  filePath: href,
@@ -620,48 +568,126 @@ function generateTree(options) {
620
568
  }
621
569
  applyOrdering(autoGenerated, options.customizations);
622
570
  return {
623
- tree: `<ul class="sidebar-list" slot="${options.sidebarLayout.slotName}">${renderTree(autoGenerated, options)}</ul>`,
571
+ tree: autoGenerated,
624
572
  notPublished
625
573
  };
626
574
  }
627
575
  //#endregion
576
+ //#region plugins/content-plugin/render-tree.ts
577
+ /** `<icon>` source for a node, `undefined` when the project has no icons directory or the node no icon. */
578
+ function iconSource(icons, icon) {
579
+ if (!icons || !icon) return;
580
+ return `${icons.replace(/\/$/, "")}/${icon}.icon.html`;
581
+ }
582
+ function renderDirectory(directory, items, { directoryLayout, customizations, icons }) {
583
+ const customization = customizations[directory.realPath];
584
+ const data = {
585
+ title: customization?.label ?? directory.title,
586
+ collapsed: customization?.collapsed ?? directory.collapsed,
587
+ ...customization?.data,
588
+ iconSrc: iconSource(icons, customization?.data?.icon)
589
+ };
590
+ return `<li class="sidebar-list-item">
591
+ <${directoryLayout.tag} dataJson='${escapeAttributeValue(JSON.stringify(data))}' ${directoryLayout.sourceAttribute}="${directoryLayout.path}">
592
+ <ul class="sidebar-list">${items}</ul>
593
+ </${directoryLayout.tag}>
594
+ </li>`;
595
+ }
596
+ function renderLink(link, { linkLayout, customizations, icons }) {
597
+ const customization = customizations[link.realPath];
598
+ const data = {
599
+ title: customization?.label ?? link.title,
600
+ fullTitle: link.pageTitle ?? link.title,
601
+ href: encodeURIComponent(link.filePath),
602
+ ...customization?.data,
603
+ iconSrc: iconSource(icons, customization?.data?.icon)
604
+ };
605
+ return `<li class="sidebar-list-item">
606
+ <${linkLayout.tag} dataJson='${escapeAttributeValue(JSON.stringify(data))}' ${linkLayout.sourceAttribute}="${linkLayout.path}"></${linkLayout.tag}>
607
+ </li>`;
608
+ }
609
+ function renderTree(items, options) {
610
+ let tree = "";
611
+ for (const item of items) {
612
+ if (isItem(item)) {
613
+ tree += renderLink(item, options);
614
+ continue;
615
+ }
616
+ tree += renderDirectory(item, renderTree(item.items, options), options);
617
+ }
618
+ return tree;
619
+ }
620
+ //#endregion
628
621
  //#region plugins/content-plugin/content-plugin.ts
629
- /**
630
- * Turns an alias, a package specifier, or an absolute path into the path the build works with: relative to the project root when
631
- * the file lives inside it, absolute otherwise.
632
- */
622
+ /** Turns an alias, a package specifier, or an absolute path into a path relative to the project root. */
633
623
  function locate(source) {
634
624
  const aliased = this.resolver.resolveAlias(source);
635
625
  if (aliased) return aliased;
636
626
  const resolved = path.isAbsolute(source) ? source : this.resolver.resolve(source, path.join(this.root, "index.html"))?.path;
637
- if (!resolved) return source;
638
- const relative = path.relative(this.root, resolved);
639
- return relative.startsWith("..") ? resolved : relative;
627
+ return resolved ? path.relative(this.root, resolved) : source;
628
+ }
629
+ /** The layout's empty `<slot name="sidebar">`, with the attributes around the name captured for the `slot` it forwards to. */
630
+ const slotRegex = /<slot\b([^>]*)\bname\s*=\s*["']sidebar["']([^>]*?)(\/>|>\s*<\/slot\s*>)/g;
631
+ /** The tree in place of the slot, sent to the same slot of the part below when the slot was forwarding to one. */
632
+ function fillSlot(content, tree) {
633
+ return content.replace(slotRegex, (_, before, after) => {
634
+ const target = /\bslot\s*=\s*["']([^"']*)["']/.exec(`${before} ${after}`)?.[1];
635
+ return `<ul class="sidebar-list"${target ? ` slot="${target}"` : ""}>${tree}</ul>`;
636
+ });
640
637
  }
641
638
  function contentPlugin(options) {
642
639
  const prepared = prepareOptions(options);
643
- const slotRegex = new RegExp(String.raw`<slot\s+name\s*=\s*['"]sidebar['"]\s*(\/>|>\s*<\/\s*slot\s*>)`, "g");
644
640
  let tree = "";
645
641
  let notPublished = /* @__PURE__ */ new Set();
646
- let sidebarRealPath = "";
642
+ /**
643
+ * The layouts this instance serves through `read`, each the file it is read from with the tree in its `sidebar` slot.
644
+ *
645
+ * @key served path, relative to the root
646
+ * @value the layout file it is a copy of
647
+ */
648
+ const served = /* @__PURE__ */ new Map();
649
+ const isInScope = (relativePath, root) => {
650
+ return path.isPathMatch(relativePath, {
651
+ include: prepared.include,
652
+ ignore: prepared.exclude,
653
+ root
654
+ });
655
+ };
656
+ const generate = (app) => {
657
+ const result = generateTree.call(app, prepared);
658
+ return {
659
+ tree: renderTree(result.tree, prepared),
660
+ notPublished: result.notPublished
661
+ };
662
+ };
647
663
  return {
648
664
  name: "content-plugin",
649
665
  setup() {
650
- for (const key in prepared.layouts) prepared.layouts[key] = locate.call(this, prepared.layouts[key]);
651
- prepared.sidebarLayout.path = locate.call(this, prepared.sidebarLayout.path);
652
- sidebarRealPath = realpathSync(path.resolve(this.root, prepared.sidebarLayout.path));
653
- const sidebarDirectory = path.dirname(path.resolve(this.root, prepared.sidebarLayout.path));
654
- const directoryLayoutPath = path.resolve(this.root, locate.call(this, prepared.directoryLayout.path));
655
- prepared.directoryLayout.path = path.relative(sidebarDirectory, directoryLayoutPath);
656
- const linkLayoutPath = path.resolve(this.root, locate.call(this, prepared.linkLayout.path));
657
- prepared.linkLayout.path = path.relative(sidebarDirectory, linkLayoutPath);
666
+ const instance = (this.pluginData.contentPluginInstances ?? 0) + 1;
667
+ this.pluginData.contentPluginInstances = instance;
668
+ for (const key in prepared.layouts) {
669
+ const original = locate.call(this, prepared.layouts[key]);
670
+ if (instance === 1) {
671
+ served.set(original, original);
672
+ continue;
673
+ }
674
+ const [name, ...rest] = path.basename(original).split(".");
675
+ const copy = path.join(path.dirname(original), [
676
+ name,
677
+ `content-${instance}`,
678
+ ...rest
679
+ ].join("."));
680
+ served.set(copy, original);
681
+ this.resolver.files.add(path.resolve(this.root, copy));
682
+ prepared.layouts[key] = copy;
683
+ }
658
684
  for (const pathKey in prepared.customizations) {
659
685
  const resolvedPath = this.resolver.resolveAlias(pathKey);
660
686
  if (!resolvedPath) continue;
661
687
  prepared.customizations[resolvedPath] = prepared.customizations[pathKey];
662
688
  delete prepared.customizations[pathKey];
663
689
  }
664
- ({tree, notPublished} = generateTree.call(this, prepared));
690
+ ({tree, notPublished} = generate(this));
665
691
  for (const file of notPublished) {
666
692
  this.entryPoints.delete(file);
667
693
  this.entryPoints.delete(`./${file}`);
@@ -672,37 +698,30 @@ function contentPlugin(options) {
672
698
  },
673
699
  read(absolutePath) {
674
700
  const relativePath = path.relative(this.root, absolutePath);
675
- if (path.basename(absolutePath) === path.basename(sidebarRealPath) && realpathSync(absolutePath) === sidebarRealPath) return readFileSync(absolutePath, "utf8").replace(slotRegex, tree);
676
- if (path.extname(relativePath) !== ".md") return;
677
- if (!path.isPathMatch(relativePath, {
678
- include: prepared.include,
679
- ignore: prepared.exclude,
680
- root: this.root
681
- })) return;
701
+ const original = served.get(relativePath);
702
+ if (original) return fillSlot(readFileSync(path.resolve(this.root, original), "utf8"), tree);
703
+ if (path.extname(relativePath) !== ".md" || !isInScope(relativePath, this.root)) return;
682
704
  const pageContent = readFileSync(absolutePath, "utf8");
683
705
  const frontMatterBlock = pageContent.match(/^---\n(.*?)\n---/s);
684
706
  const frontMatter = {
685
707
  layout: "default",
686
708
  ...frontMatterBlock ? yaml.parse(frontMatterBlock[1]) : {}
687
709
  };
688
- const layoutPath = prepared.layouts[frontMatter.layout] ?? prepared.layouts.default;
689
- frontMatter.layout = path.relative(path.dirname(relativePath), layoutPath);
710
+ const layout = prepared.layouts[frontMatter.layout] ?? prepared.layouts.default;
711
+ frontMatter.layout = served.has(layout) ? path.relative(path.dirname(absolutePath), path.resolve(this.root, layout)) : layout;
690
712
  for (const key in frontMatter) if (key === "publish" || key.startsWith("sidebar.")) delete frontMatter[key];
691
713
  const body = frontMatterBlock ? pageContent.slice(frontMatterBlock[0].length) : pageContent;
692
714
  return `---\n${yaml.stringify(frontMatter)}\n---\n${body}`;
693
715
  },
694
716
  onFileEvent(_, id) {
695
- if (!path.isPathMatch(id, {
696
- include: prepared.include,
697
- ignore: prepared.exclude,
698
- root: this.root
699
- })) return;
700
- const result = generateTree.call(this, prepared);
717
+ for (const [copy, original] of served) if (original === id && copy !== id) this.watcher?.emit("change", copy);
718
+ if (!isInScope(id, this.root)) return;
719
+ const result = generate(this);
701
720
  notPublished = result.notPublished;
702
721
  if (tree === result.tree) return;
703
722
  this.log.info("Re-generating sidebar tree");
704
723
  tree = result.tree;
705
- this.watcher?.emit("change", prepared.sidebarLayout.path);
724
+ for (const layout of served.keys()) this.watcher?.emit("change", layout);
706
725
  }
707
726
  };
708
727
  }
@@ -755,14 +774,14 @@ const markdownLink = /\[([^\]]+)\]\([^)]*\)/g;
755
774
  * needs no link-following. Nothing is curated by hand.
756
775
  */
757
776
  function llmsPlugin(options = {}) {
758
- const { siteUrl, title = "Documentation", summary } = options;
777
+ const { siteUrl, title = "Documentation", summary, include = ["pages/**/*.md"], exclude = [], output = "llms" } = options;
759
778
  return {
760
779
  name: "llms",
761
780
  async postBuild() {
762
781
  if (!siteUrl) return;
763
782
  const origin = siteUrl.replace(/\/+$/, "");
764
783
  const isBuilt = (file) => Boolean(this.findMetadata({ id: file }) ?? this.findMetadata({ id: `./${file}` }));
765
- const entries = await collectPages(this.root, isBuilt);
784
+ const entries = await collectPages(this.root, isBuilt, include, exclude);
766
785
  if (entries.length === 0) return;
767
786
  for (const entry of entries) {
768
787
  const target = join(this.outdir, entry.route, "index.md");
@@ -787,24 +806,30 @@ function llmsPlugin(options = {}) {
787
806
  manifest.push(`- [${entry.title}](${url})${entry.description ? `: ${entry.description}` : ""}`);
788
807
  }
789
808
  }
790
- await writeFile(join(this.outdir, "llms.txt"), manifest.join("\n") + "\n", "utf8");
809
+ const manifestFile = `${output}.txt`;
810
+ const fullFile = `${output}-full.txt`;
811
+ await mkdir(dirname(join(this.outdir, manifestFile)), { recursive: true });
812
+ await writeFile(join(this.outdir, manifestFile), manifest.join("\n") + "\n", "utf8");
791
813
  const full = [[
792
814
  ...heading,
793
815
  "",
794
- `> Every document from ${origin}/llms.txt inlined in full, so no links need following.`
816
+ `> Every document from ${origin}/${manifestFile} inlined in full, so no links need following.`
795
817
  ].join("\n"), ...entries.map((entry) => {
796
818
  const source = `${origin}/${entry.route}${entry.route ? "/" : ""}index.md`;
797
819
  return `${demoteHeadings(entry.document, entry.title)}\n\nSource: ${source}`;
798
820
  })].join("\n\n---\n\n") + "\n";
799
- await writeFile(join(this.outdir, "llms-full.txt"), rewriteAliases(full, origin), "utf8");
821
+ await writeFile(join(this.outdir, fullFile), rewriteAliases(full, origin), "utf8");
800
822
  const kilobytes = Math.round(Buffer.byteLength(full) / 1024);
801
- this.log.success(`Wrote llms.txt (${entries.length} links) and llms-full.txt (${kilobytes} KB).`);
823
+ this.log.success(`Wrote ${manifestFile} (${entries.length} links) and ${fullFile} (${kilobytes} KB).`);
802
824
  }
803
825
  };
804
826
  }
805
827
  /** Pages the build produced, so anything the markdown plugin dropped (`publish: false`) stays out of the llms files too. */
806
- async function collectPages(root, isBuilt) {
807
- const files = globSync("pages/**/*.md", { cwd: root }).map((file) => file.replaceAll("\\", "/")).filter((file) => isBuilt(file)).toSorted(byDepthThenPath);
828
+ async function collectPages(root, isBuilt, include, exclude) {
829
+ const files = globSync(include, {
830
+ cwd: root,
831
+ exclude
832
+ }).map((file) => file.replaceAll("\\", "/")).filter((file) => isBuilt(file)).toSorted(byDepthThenPath);
808
833
  const entries = [];
809
834
  for (const file of files) {
810
835
  const document = clean(await readFile(join(root, file), "utf8"));
@@ -890,6 +915,13 @@ function byDepthThenPath(a, b) {
890
915
  }
891
916
  //#endregion
892
917
  //#region plugins/mermaid-fence.ts
918
+ /** The sole `<code>` child of a `<pre>`, ignoring the whitespace some serialisers insert, or nothing when the shape differs. */
919
+ function onlyCodeChild(node) {
920
+ const children = node.children.filter((child) => !(child.type === "text" && child.value.trim() === ""));
921
+ const [child] = children;
922
+ if (children.length !== 1 || child.type !== "element" || child.tagName !== "code") return;
923
+ return child;
924
+ }
893
925
  /**
894
926
  * Rehype plugin: converts fenced mermaid code blocks into <pre class="mermaid"> elements.
895
927
  *
@@ -900,60 +932,21 @@ function byDepthThenPath(a, b) {
900
932
  const rehypeMermaidFence = () => {
901
933
  return (tree) => {
902
934
  visit(tree, "element", (node) => {
903
- if (!isPreElement(node)) return;
904
- const codeElement = findSingleCodeChild(node);
905
- if (codeElement === null) return;
906
- if (!hasMermaidLanguageClass(codeElement)) return;
907
- const mermaidSource = extractTextContent(codeElement);
908
- if (mermaidSource === null) return;
935
+ if (node.tagName !== "pre") return;
936
+ const code = onlyCodeChild(node);
937
+ if (!code) return;
938
+ const className = code.properties?.className;
939
+ if (!(Array.isArray(className) ? className : [className]).some((name) => String(name) === "language-mermaid")) return;
940
+ const text = code.children.filter((child) => child.type === "text");
941
+ if (text.length === 0) return;
909
942
  node.properties = { className: ["mermaid"] };
910
943
  node.children = [{
911
944
  type: "text",
912
- value: mermaidSource
945
+ value: text.map((child) => child.value).join("")
913
946
  }];
914
947
  });
915
948
  };
916
949
  };
917
- function isPreElement(node) {
918
- return node.tagName === "pre";
919
- }
920
- /** Returns the sole <code> child of a <pre>, or null if the structure doesn't match (e.g. multiple children, wrong tag). */
921
- function findSingleCodeChild(preNode) {
922
- const nonWhitespaceChildren = [];
923
- for (const child of preNode.children) {
924
- if (child.type === "text" && child.value.trim() === "") continue;
925
- nonWhitespaceChildren.push(child);
926
- }
927
- if (nonWhitespaceChildren.length !== 1) return null;
928
- const [onlyChild] = nonWhitespaceChildren;
929
- if (onlyChild.type !== "element") return null;
930
- if (onlyChild.tagName !== "code") return null;
931
- return onlyChild;
932
- }
933
- /**
934
- * Returns true when the element's className list contains "language-mermaid". Handles both string and array forms that hast
935
- * allows.
936
- */
937
- function hasMermaidLanguageClass(codeElement) {
938
- const rawClassName = codeElement.properties?.className;
939
- if (rawClassName === void 0 || rawClassName === null) return false;
940
- const classNames = Array.isArray(rawClassName) ? rawClassName : [String(rawClassName)];
941
- for (const className of classNames) if (className === "language-mermaid") return true;
942
- return false;
943
- }
944
- /**
945
- * Concatenates all text-node descendants of an element. Returns null when there are no text nodes (empty code block is valid →
946
- * ""). A <code> block produced by remark-rehype always has exactly one Text child, but we handle edge cases defensively.
947
- */
948
- function extractTextContent(element) {
949
- let content = "";
950
- let found = false;
951
- for (const child of element.children) if (child.type === "text") {
952
- content += child.value;
953
- found = true;
954
- }
955
- return found ? content : null;
956
- }
957
950
  //#endregion
958
951
  //#region plugins/obsidian-comments.ts
959
952
  const commentPattern = /(?<!\\)%%[\s\S]*?(?<!\\)%%/g;
@@ -1108,18 +1101,17 @@ function pagefindPlugin({ glob = "**/*.html" } = {}) {
1108
1101
  //#region plugins/wrap-table.ts
1109
1102
  function wrapTables() {
1110
1103
  return (tree) => {
1111
- const tables = selectAll("table", tree);
1112
- for (const table of tables) visit(tree, table, (node, index, parent) => {
1113
- if (!parent || typeof index !== "number") return;
1114
- const wrapper = {
1104
+ visit(tree, "element", (node, index, parent) => {
1105
+ if (node.tagName !== "table" || !parent || typeof index !== "number") return;
1106
+ parent.children[index] = {
1115
1107
  type: "element",
1116
1108
  tagName: "div",
1117
1109
  properties: { class: "table-wrapper" },
1118
1110
  children: [node]
1119
1111
  };
1120
- parent.children[index] = wrapper;
1112
+ return "skip";
1121
1113
  });
1122
1114
  };
1123
1115
  }
1124
1116
  //#endregion
1125
- export { HtmlCodeBlockPlugin, PersistentCache, cachedRehypeExpressiveCode, contentPlugin, ecCachePlugin, ecCacheSalt, htmlWithTypeScriptScripts, katexFontsPlugin, llmsPlugin, normalizeTitle, pagefindPlugin, pluginCollapsible, rehypeMermaidFence, remarkObsidianComments, remarkObsidianTags, sha1, wrapTables };
1117
+ export { PersistentCache, cachedRehypeExpressiveCode, contentPlugin, ecCachePlugin, ecCacheSalt, htmlCodeBlockPlugin, htmlWithTypeScriptScripts, katexFontsPlugin, llmsPlugin, normalizeTitle, pagefindPlugin, pluginCollapsible, rehypeMermaidFence, remarkObsidianComments, remarkObsidianTags, sha1, wrapTables };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staticbolt/docs",
3
- "version": "0.0.1-beta.2",
3
+ "version": "0.0.1-beta.4",
4
4
  "description": "Documentation theme for staticbolt: layout parts, styles, and plugins shared by every docs site.",
5
5
  "author": "Ahmed ALABSI",
6
6
  "license": "MIT",
@@ -66,7 +66,6 @@
66
66
  "@pagefind/component-ui": "^1.5.2",
67
67
  "@xt0rted/expressive-code-file-icons": "^1.0.0",
68
68
  "expressive-code": "^0.42.0",
69
- "hast-util-select": "^6.0.2",
70
69
  "hast-util-to-html": "^9.0.5",
71
70
  "pagefind": "^1.5.2",
72
71
  "rehype-expressive-code": "^0.42.0",
@@ -76,7 +75,14 @@
76
75
  },
77
76
  "overrides": {
78
77
  "expressive-code": "^0.42.0",
79
- "@expressive-code/core": "^0.42.0"
78
+ "@expressive-code/core": "^0.42.0",
79
+ "d3-array": "^3.2.4",
80
+ "d3-shape": "^3.2.0",
81
+ "d3-path": "^3.1.0",
82
+ "internmap": "^2.0.3",
83
+ "marked": "^18.0.11",
84
+ "cose-base": "^2.2.0",
85
+ "layout-base": "^2.0.1"
80
86
  },
81
87
  "devDependencies": {
82
88
  "@eslint/js": "^10.0.1",
@@ -2,8 +2,8 @@
2
2
  <script type="application/x-typescript" build-time>
3
3
  for (const callout of document.querySelectorAll("details.callout")) {
4
4
  const accordion = document.createElement("sv-accordion");
5
- accordion.setAttribute("data-callout", callout.getAttribute("data-callout"));
6
- accordion.setAttribute("class", callout.getAttribute("class"));
5
+ accordion.setAttribute("data-callout", callout.getAttribute("data-callout") || "");
6
+ accordion.setAttribute("class", callout.getAttribute("class") || "");
7
7
  accordion.setAttribute("heading-level", "none");
8
8
 
9
9
  if (callout.hasAttribute("open")) {
@@ -77,12 +77,7 @@
77
77
 
78
78
  document.addEventListener("DOMContentLoaded", onReady, { once: true, passive: true });
79
79
 
80
- /**
81
- * @param {() => void} update
82
- * @param {number} x
83
- * @param {number} y
84
- */
85
- async function viewTransition(update, x, y) {
80
+ async function viewTransition(update: () => void, x: number, y: number) {
86
81
  if (!document.startViewTransition || matchMedia("(prefers-reduced-motion: reduce)").matches) {
87
82
  update();
88
83
  return;
@@ -5,6 +5,7 @@
5
5
  @attr maxHeading - Highest heading level in the table of contents. Default 3.
6
6
  @attr mermaid - Present to render mermaid fences. Needs the mermaid package.
7
7
  @slot - The page content.
8
+ @slot sidebar - The sidebar tree, filled in by contentPlugin through the layout.
8
9
  @slot logo - Rendered before the title in the header link.
9
10
  @slot icons - Links rendered in the header and the sidebar footer.
10
11
  -->
@@ -21,6 +22,7 @@
21
22
 
22
23
  <div class="markdown-layout">
23
24
  <part src="@staticbolt/docs/parts/sidebar/sidebar.part.html">
25
+ <slot name="sidebar"></slot>
24
26
  <slot name="icons" slot="icons"></slot>
25
27
  </part>
26
28
 
@@ -29,11 +29,11 @@
29
29
  // ::: Get current link and populate previous/next links :::
30
30
  import { join, dirname, normalize } from "node:path";
31
31
 
32
- const paginationPrev = document.querySelector(".pagination-link-prev");
33
- const paginationPrevTitle = paginationPrev.querySelector(".link-title");
32
+ const paginationPrev = document.querySelector(".pagination-link-prev")!;
33
+ const paginationPrevTitle = paginationPrev.querySelector(".link-title")!;
34
34
 
35
- const paginationNext = document.querySelector(".pagination-link-next");
36
- const paginationNextTitle = paginationNext.querySelector(".link-title");
35
+ const paginationNext = document.querySelector(".pagination-link-next")!;
36
+ const paginationNextTitle = paginationNext.querySelector(".link-title")!;
37
37
 
38
38
  // You can find `.sidebar` in the documentation layout
39
39
  const navigationLinks = document.querySelectorAll(".sidebar a.sidebar-link");
@@ -50,7 +50,7 @@
50
50
  const previousLink = navigationLinks[linkIndex - 1];
51
51
  if (previousLink) {
52
52
  const prevHref = previousLink.getAttribute("href");
53
- paginationPrev.setAttribute("href", prevHref);
53
+ paginationPrev.setAttribute("href", prevHref || "#");
54
54
 
55
55
  const prevTitle = previousLink.getAttribute("data-title") || previousLink.textContent;
56
56
  paginationPrevTitle.textContent = prevTitle;
@@ -62,7 +62,7 @@
62
62
  const nextLink = navigationLinks[linkIndex + 1];
63
63
  if (nextLink) {
64
64
  const nextHref = nextLink.getAttribute("href");
65
- paginationNext.setAttribute("href", nextHref);
65
+ paginationNext.setAttribute("href", nextHref || "#");
66
66
 
67
67
  const nextTitle = nextLink.getAttribute("data-title") || nextLink.textContent;
68
68
  paginationNextTitle.textContent = nextTitle;
@@ -1,6 +1,6 @@
1
1
  <div class="sidebar">
2
2
  <nav aria-label="Sidebar navigation">
3
- <slot name="sidebar"></slot>
3
+ <slot></slot>
4
4
  </nav>
5
5
 
6
6
  <div class="sidebar-footer">
@@ -22,7 +22,7 @@
22
22
  const navigationLinks = document.querySelectorAll(".sidebar a");
23
23
  const currentFile = normalize(__filepath);
24
24
 
25
- for (const [linkIndex, link] of navigationLinks.entries()) {
25
+ for (const [, link] of navigationLinks.entries()) {
26
26
  const linkPath = join(dirname(__filepath), link.attributes.href, "index.html");
27
27
  if (linkPath !== currentFile) continue;
28
28
 
@@ -43,8 +43,10 @@
43
43
 
44
44
  <!-- ::: Persist sidebar navigation state across page loads. ::: -->
45
45
  <script type="application/x-typescript">
46
+ import type { Accordion } from "@staticview/ui/accordion";
47
+
46
48
  (() => {
47
- const accordions = document.querySelectorAll<HTMLDivElement>(".sidebar-accordion");
49
+ const accordions = document.querySelectorAll<Accordion>(".sidebar-accordion");
48
50
 
49
51
  // ::: Restore the accordion's previous open state. :::
50
52
  try {
@@ -67,7 +69,7 @@
67
69
 
68
70
  // ::: Save accordion open state when toggled. :::
69
71
  const accordionToggleHandler = () => {
70
- const state = {};
72
+ const state: Record<string, boolean> = {};
71
73
 
72
74
  for (const [accordionIndex, accordion] of accordions.entries()) {
73
75
  state[accordionIndex] = accordion.opened;
@@ -81,7 +83,8 @@
81
83
  }
82
84
 
83
85
  // ::: Restore sidebar scroll position. :::
84
- const sidebar = document.querySelector(".sidebar");
86
+ const sidebar = document.querySelector<HTMLElement>(".sidebar");
87
+ if (!sidebar) return;
85
88
 
86
89
  const onReady = () => {
87
90
  // First rAF: end of current task, second rAF: after first paint
@@ -97,7 +100,7 @@
97
100
 
98
101
  // ::: Save sidebar scroll position. :::
99
102
  const sidebarScrollHandler = () => {
100
- sessionStorage.setItem("sidebar-scroll", sidebar.scrollTop);
103
+ sessionStorage.setItem("sidebar-scroll", String(sidebar.scrollTop));
101
104
  };
102
105
 
103
106
  sidebar.addEventListener("scroll", sidebarScrollHandler, { passive: true });
@@ -71,17 +71,18 @@
71
71
  const links = document.querySelectorAll(".toc-list a");
72
72
 
73
73
  // Mobile TOC current title
74
- const currentTitle = document.querySelector(".toc-current-title");
74
+ const currentTitle = document.querySelector<HTMLElement>(".toc-current-title");
75
75
 
76
76
  const headings = Array.from(links)
77
- .map(a => document.getElementById(a.getAttribute("href").slice(1)))
78
- .filter(Boolean);
77
+ .map(a => document.getElementById(a.getAttribute("href")?.slice(1) ?? ""))
78
+ .filter(Boolean) as HTMLElement[];
79
79
 
80
- function setActive(id) {
80
+ function setActive(id: string) {
81
81
  for (const link of links) {
82
82
  const active = link.getAttribute("href") === "#" + id;
83
83
  link.classList.toggle("active", active);
84
- if (active) {
84
+
85
+ if (active && currentTitle) {
85
86
  currentTitle.textContent = link.textContent;
86
87
  }
87
88
  }
@@ -90,8 +91,8 @@
90
91
  function update() {
91
92
  if (!headings.length) return;
92
93
 
93
- let scrollPaddingTop = getComputedStyle(document.documentElement).getPropertyValue("scroll-padding-top");
94
- scrollPaddingTop = parseFloat(scrollPaddingTop.replace("px", "").trim());
94
+ const scrollPaddingTopStyle = getComputedStyle(document.documentElement).getPropertyValue("scroll-padding-top");
95
+ let scrollPaddingTop = parseFloat(scrollPaddingTopStyle.replace("px", "").trim());
95
96
  scrollPaddingTop = !isFinite(scrollPaddingTop) ? 50 : scrollPaddingTop + 2;
96
97
 
97
98
  let active = headings[0];
@@ -13,12 +13,16 @@
13
13
 
14
14
  <!-- ::: Close the accordion when a link is clicked ::: -->
15
15
  <script type="application/x-typescript" module>
16
- const tocAccordion = document.querySelector(".toc-accordion");
16
+ import type { Accordion } from "@staticview/ui/accordion";
17
+
18
+ const tocAccordion = document.querySelector<Accordion>(".toc-accordion");
17
19
  const tocLinks = document.querySelectorAll(".toc-accordion a");
18
20
 
19
21
  for (const link of tocLinks) {
20
22
  link.addEventListener("click", () => {
21
- tocAccordion.opened = false;
23
+ if (tocAccordion) {
24
+ tocAccordion.opened = false;
25
+ }
22
26
  });
23
27
  }
24
28
  </script>