starlight-cannoli-plugins 2.7.0 → 2.8.0

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
@@ -399,6 +399,7 @@ An Astro integration that injects a client-side script to apply opt-in DOM patch
399
399
  - `hideSingleLineGutters` (optional, default: `false`): Hides the line number gutter on Expressive Code blocks that contain only a single line.
400
400
  - `syncTocLabelsFromHeadings` (optional, default: `false`): Copies the rendered HTML of each heading into its matching Starlight TOC anchor label, so the TOC properly reflects any custom markup (e.g. math) present in the heading.
401
401
  - `wrapDetailsContent` (optional, default: `false`): Wraps the content of every `<details>` element (excluding its `<summary>`) in a `<div class="details-wrapper">`, useful for applying consistent spacing or animation styles.
402
+ - `offerTabbedContent` (optional, default: `false`): Injects a "Tabbed view" toggle checkbox immediately after `#starlight__on-this-page` in the right sidebar. When enabled by the user, the page's markdown content is reorganised into tabs — one per `<h2>` heading, plus an optional "Main" tab for any content that appears before the first `<h2>`. The toggle state is persisted to `localStorage`; active tab selection is not persisted. Only activates when the page has at least two sections (i.e. two or more `<h2>` elements, or one `<h2>` with pre-heading content). Has no effect on pages that lack `#starlight__on-this-page` (e.g. pages with the TOC disabled). Clicking a TOC anchor while tabs are enabled automatically switches to the tab containing the target heading. The generated elements use the classes `tabbed-content`, `tabbed-content-nav`, `tabbed-content-tab`, and `tabbed-content-panel` for styling; toggle button uses the existing `.toggle-checkbox-btn` class.
402
403
 
403
404
  **Usage:**
404
405
 
@@ -414,6 +415,7 @@ export default defineConfig({
414
415
  hideSingleLineGutters: true,
415
416
  syncTocLabelsFromHeadings: true,
416
417
  wrapDetailsContent: true,
418
+ offerTabbedContent: true,
417
419
  }),
418
420
  starlight({ title: "My Docs" }),
419
421
  ],
@@ -4,7 +4,8 @@ function starlightDomPatches(options = {}) {
4
4
  const {
5
5
  hideSingleLineGutters = false,
6
6
  syncTocLabelsFromHeadings = false,
7
- wrapDetailsContent = false
7
+ wrapDetailsContent = false,
8
+ offerTabbedContent = false
8
9
  } = options;
9
10
  return {
10
11
  name: "starlight-dom-patches",
@@ -29,7 +30,8 @@ function starlightDomPatches(options = {}) {
29
30
  const imports = [
30
31
  hideSingleLineGutters ? "hideSingleLineGutters" : null,
31
32
  syncTocLabelsFromHeadings ? "syncTocLabelsFromHeadings" : null,
32
- wrapDetailsContent ? "wrapDetailsContent" : null
33
+ wrapDetailsContent ? "wrapDetailsContent" : null,
34
+ offerTabbedContent ? "tabbedH2Content" : null
33
35
  ].filter(Boolean);
34
36
  if (imports.length === 0) return;
35
37
  const calls = imports.map((fn) => `${fn}();`).join("\n");
package/dist/index.d.ts CHANGED
@@ -13,6 +13,12 @@ import 'vfile';
13
13
  import 'mdast';
14
14
  import 'expressive-code';
15
15
 
16
+ type TBadgeVariant = "note" | "danger" | "success" | "caution" | "tip" | "default";
17
+ type TIndexMarker = {
18
+ text: string;
19
+ variant: TBadgeVariant;
20
+ };
21
+
16
22
  type TOptions = {
17
23
  /**
18
24
  * Controls how deep the nested group structure can go, where the root directory is level 0.
@@ -32,7 +38,13 @@ type TOptions = {
32
38
  * regular sidebar entries. When undefined, no marker is added.
33
39
  * @example "★"
34
40
  */
35
- indexMarker?: string;
41
+ indexMarker?: TIndexMarker;
42
+ /**
43
+ * Whether sidebar groups should be collapsed by default. Starlight automatically expands
44
+ * the group containing the current page regardless of this setting.
45
+ * @default true
46
+ */
47
+ collapsed?: boolean;
36
48
  };
37
49
  /**
38
50
  * Starlight plugin that generates a sidebar by parsing markdown links from index.md files.
package/dist/index.js CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  } from "./chunk-NCXV367P.js";
25
25
  import {
26
26
  starlightDomPatches
27
- } from "./chunk-XMGAWBJ5.js";
27
+ } from "./chunk-OXJMUUGP.js";
28
28
  import "./chunk-4VNS5WPM.js";
29
29
 
30
30
  // src/plugins/starlight-index-sourced-sidebar/index.ts
@@ -110,7 +110,7 @@ function tryResolveMarkdownFile(href, currentDir) {
110
110
  }
111
111
  return findMarkdownFile(resolved);
112
112
  }
113
- function buildItems(dirPath, currentDepth, maxDepth, visited, indexMarker) {
113
+ function buildItems(dirPath, currentDepth, maxDepth, visited, indexMarker, collapsed) {
114
114
  const absDir = path.resolve(dirPath);
115
115
  if (visited.has(absDir)) return [];
116
116
  visited.add(absDir);
@@ -125,8 +125,9 @@ function buildItems(dirPath, currentDepth, maxDepth, visited, indexMarker) {
125
125
  const items = [];
126
126
  const indexBaseLabel = fm.title ?? pathSegmentToLabel(path.basename(absDir));
127
127
  items.push({
128
- label: indexMarker ? `${indexMarker} ${indexBaseLabel}` : indexBaseLabel,
129
- slug: normalizeSlug(filePathToSlug(indexFile))
128
+ label: indexBaseLabel,
129
+ slug: normalizeSlug(filePathToSlug(indexFile)),
130
+ ...indexMarker && { badge: indexMarker }
130
131
  });
131
132
  for (const href of extractMarkdownLinks(indexFile)) {
132
133
  if (isExternal(href)) continue;
@@ -143,8 +144,9 @@ function buildItems(dirPath, currentDepth, maxDepth, visited, indexMarker) {
143
144
  continue;
144
145
  const subBaseLabel = subFm.title ?? pathSegmentToLabel(path.basename(subDirPath));
145
146
  items.push({
146
- label: indexMarker ? `${indexMarker} ${subBaseLabel}` : subBaseLabel,
147
- slug: normalizeSlug(filePathToSlug(resolvedFile))
147
+ label: subBaseLabel,
148
+ slug: normalizeSlug(filePathToSlug(resolvedFile)),
149
+ ...indexMarker && { badge: indexMarker }
148
150
  });
149
151
  } else {
150
152
  const subItems = buildItems(
@@ -152,7 +154,8 @@ function buildItems(dirPath, currentDepth, maxDepth, visited, indexMarker) {
152
154
  currentDepth + 1,
153
155
  maxDepth,
154
156
  visited,
155
- indexMarker
157
+ indexMarker,
158
+ collapsed
156
159
  );
157
160
  if (subItems.length === 0) continue;
158
161
  if (currentDepth + 1 >= maxDepth) {
@@ -160,7 +163,8 @@ function buildItems(dirPath, currentDepth, maxDepth, visited, indexMarker) {
160
163
  } else {
161
164
  items.push({
162
165
  label: pathSegmentToLabel(path.basename(subDirPath)),
163
- items: subItems
166
+ items: subItems,
167
+ collapsed
164
168
  });
165
169
  }
166
170
  }
@@ -178,7 +182,7 @@ function buildItems(dirPath, currentDepth, maxDepth, visited, indexMarker) {
178
182
  }
179
183
  return items;
180
184
  }
181
- function getIndexSourcedSidebarItems(directory, maxDepthNesting = 100, indexMarker) {
185
+ function getIndexSourcedSidebarItems(directory, maxDepthNesting = 100, indexMarker, collapsed = true) {
182
186
  const absDir = path.resolve(directory);
183
187
  const rootGroupLabel = pathSegmentToLabel(path.basename(absDir));
184
188
  const items = buildItems(
@@ -186,10 +190,11 @@ function getIndexSourcedSidebarItems(directory, maxDepthNesting = 100, indexMark
186
190
  0,
187
191
  maxDepthNesting,
188
192
  /* @__PURE__ */ new Set(),
189
- indexMarker
193
+ indexMarker,
194
+ collapsed
190
195
  );
191
196
  if (items.length === 0) return [];
192
- return [{ label: rootGroupLabel, items }];
197
+ return [{ label: rootGroupLabel, items, collapsed }];
193
198
  }
194
199
 
195
200
  // src/plugins/starlight-index-sourced-sidebar/index.ts
@@ -200,13 +205,14 @@ function starlightIndexSourcedSidebar(options) {
200
205
  hooks: {
201
206
  "config:setup": (hookOptions) => {
202
207
  const { updateConfig } = hookOptions;
203
- const { directories, maxDepthNesting = 100, indexMarker } = options;
208
+ const { directories, maxDepthNesting = 100, indexMarker, collapsed = true } = options;
204
209
  const sidebarItems = directories.map((directory) => {
205
210
  const dirPath = join2(SITE_DOCS_ROOT2, directory);
206
211
  const rawItems = getIndexSourcedSidebarItems(
207
212
  dirPath,
208
213
  maxDepthNesting,
209
- indexMarker
214
+ indexMarker,
215
+ collapsed
210
216
  );
211
217
  const rootGroup = rawItems[0];
212
218
  if (!rootGroup || !("items" in rootGroup)) return void 0;
@@ -1,5 +1,6 @@
1
1
  declare function hideSingleLineGutters(): void;
2
2
  declare function syncTocLabelsFromHeadings(): void;
3
+ declare function tabbedH2Content(): void;
3
4
  declare function wrapDetailsContent(): void;
4
5
 
5
- export { hideSingleLineGutters, syncTocLabelsFromHeadings, wrapDetailsContent };
6
+ export { hideSingleLineGutters, syncTocLabelsFromHeadings, tabbedH2Content, wrapDetailsContent };
@@ -16,6 +16,12 @@ function hideSingleLineGutters() {
16
16
  }
17
17
  });
18
18
  }
19
+ function headingInnerHTML(heading) {
20
+ if (heading.childElementCount === 1 && heading.children[0].tagName === "STRONG") {
21
+ return heading.children[0].innerHTML;
22
+ }
23
+ return heading.innerHTML;
24
+ }
19
25
  function syncTocLabelsFromHeadings() {
20
26
  document.querySelectorAll("starlight-toc ul li > a, mobile-starlight-toc ul li > a").forEach((anchor) => {
21
27
  const href = anchor.getAttribute("href") ?? "";
@@ -27,13 +33,152 @@ function syncTocLabelsFromHeadings() {
27
33
  if (!heading) return;
28
34
  const span = anchor.querySelector(":scope > span");
29
35
  if (!span) return;
30
- span.innerHTML = heading.innerHTML;
36
+ span.innerHTML = headingInnerHTML(heading);
37
+ });
38
+ }
39
+ function tabbedH2Content() {
40
+ console.log("[tabbedH2Content] running");
41
+ const LS_KEY = "starlight-dom-patches:tabbed-content";
42
+ const container = document.querySelector(
43
+ ".main-pane .sl-markdown-content"
44
+ );
45
+ if (!container) {
46
+ console.log(
47
+ "[tabbedH2Content] no .main-pane .sl-markdown-content found \u2014 aborting"
48
+ );
49
+ return;
50
+ }
51
+ const tocSection = document.getElementById("starlight__on-this-page");
52
+ if (!tocSection) {
53
+ console.log(
54
+ "[tabbedH2Content] #starlight__on-this-page not found \u2014 aborting"
55
+ );
56
+ return;
57
+ }
58
+ const children = Array.from(container.children);
59
+ if (children.length === 0) {
60
+ console.log("[tabbedH2Content] content container is empty \u2014 aborting");
61
+ return;
62
+ }
63
+ console.log(`[tabbedH2Content] container element:`, container);
64
+ console.log(
65
+ `[tabbedH2Content] found ${children.length} children:`,
66
+ children.map((c) => c.tagName).join(", ")
67
+ );
68
+ const preH2Nodes = [];
69
+ const h2Sections = [];
70
+ let currentSection = null;
71
+ const isH2Wrapper = (el) => el.tagName === "DIV" && el.classList.contains("sl-heading-wrapper") && el.classList.contains("level-h2");
72
+ for (const child of children) {
73
+ if (isH2Wrapper(child)) {
74
+ if (currentSection) h2Sections.push(currentSection);
75
+ const h2 = child.querySelector("h2");
76
+ currentSection = {
77
+ label: h2 ? headingInnerHTML(h2) : "",
78
+ nodes: [child]
79
+ };
80
+ } else if (currentSection === null) {
81
+ preH2Nodes.push(child);
82
+ } else {
83
+ currentSection.nodes.push(child);
84
+ }
85
+ }
86
+ if (currentSection) h2Sections.push(currentSection);
87
+ const hasPreContent = preH2Nodes.length > 0;
88
+ const totalSections = (hasPreContent ? 1 : 0) + h2Sections.length;
89
+ console.log(
90
+ `[tabbedH2Content] preH2Nodes: ${preH2Nodes.length}, h2Sections: ${h2Sections.length}, totalSections: ${totalSections}`
91
+ );
92
+ if (totalSections <= 1) {
93
+ console.log(
94
+ "[tabbedH2Content] only one section \u2014 aborting (no tabs needed)"
95
+ );
96
+ return;
97
+ }
98
+ const allSections = [];
99
+ if (hasPreContent) allSections.push({ label: "Main", nodes: preH2Nodes });
100
+ allSections.push(...h2Sections);
101
+ const wrapper = document.createElement("div");
102
+ wrapper.className = "tabbed-content";
103
+ const nav = document.createElement("div");
104
+ nav.className = "tabbed-content-nav not-content";
105
+ const tabButtons = [];
106
+ const panels = [];
107
+ allSections.forEach((section, i) => {
108
+ const btn = document.createElement("button");
109
+ btn.className = "tabbed-content-tab";
110
+ btn.innerHTML = section.label;
111
+ btn.dataset.tab = String(i);
112
+ tabButtons.push(btn);
113
+ nav.appendChild(btn);
114
+ const panel = document.createElement("div");
115
+ panel.className = "tabbed-content-panel";
116
+ panel.dataset.panel = String(i);
117
+ section.nodes.forEach((node) => panel.appendChild(node));
118
+ panels.push(panel);
31
119
  });
32
- document.querySelectorAll("starlight-toc ul > li > a > span").forEach((span) => {
33
- if (span.childElementCount !== 1) return;
34
- const child = span.children[0];
35
- if (child.tagName !== "STRONG") return;
36
- span.innerHTML = child.innerHTML;
120
+ wrapper.appendChild(nav);
121
+ panels.forEach((p) => wrapper.appendChild(p));
122
+ container.appendChild(wrapper);
123
+ let activeTab = 0;
124
+ function activateTab(index) {
125
+ activeTab = index;
126
+ tabButtons.forEach((btn, i) => {
127
+ btn.dataset.active = String(i === index);
128
+ });
129
+ panels.forEach((panel, i) => {
130
+ panel.hidden = i !== index;
131
+ });
132
+ }
133
+ function setEnabled(enabled) {
134
+ wrapper.dataset.enabled = String(enabled);
135
+ nav.hidden = !enabled;
136
+ if (enabled) {
137
+ activateTab(activeTab);
138
+ } else {
139
+ panels.forEach((panel) => {
140
+ panel.hidden = false;
141
+ });
142
+ }
143
+ }
144
+ tabButtons.forEach((btn, i) => {
145
+ btn.addEventListener("click", () => {
146
+ if (wrapper.dataset.enabled === "true") activateTab(i);
147
+ });
148
+ });
149
+ function navigateToHash(hash) {
150
+ if (!hash || wrapper.dataset.enabled !== "true") return;
151
+ const id = hash.startsWith("#") ? hash.slice(1) : hash;
152
+ panels.forEach((panel, i) => {
153
+ if (panel.querySelector(`#${CSS.escape(id)}`)) activateTab(i);
154
+ });
155
+ }
156
+ window.addEventListener(
157
+ "hashchange",
158
+ () => navigateToHash(window.location.hash)
159
+ );
160
+ if (window.location.hash) navigateToHash(window.location.hash);
161
+ const toggleLabel = document.createElement("label");
162
+ toggleLabel.className = "toggle-checkbox-btn";
163
+ const checkbox = document.createElement("input");
164
+ checkbox.type = "checkbox";
165
+ const toggleText = document.createElement("span");
166
+ toggleText.textContent = "Tabbed view";
167
+ toggleLabel.appendChild(checkbox);
168
+ toggleLabel.appendChild(toggleText);
169
+ tocSection.parentNode.insertBefore(toggleLabel, tocSection.nextSibling);
170
+ console.log(
171
+ "[tabbedH2Content] toggle checkbox injected after #starlight__on-this-page"
172
+ );
173
+ const initialEnabled = localStorage.getItem(LS_KEY) === "enabled";
174
+ console.log(
175
+ `[tabbedH2Content] localStorage value: ${localStorage.getItem(LS_KEY)}, initialEnabled: ${initialEnabled}`
176
+ );
177
+ checkbox.checked = initialEnabled;
178
+ setEnabled(initialEnabled);
179
+ checkbox.addEventListener("change", () => {
180
+ setEnabled(checkbox.checked);
181
+ localStorage.setItem(LS_KEY, checkbox.checked ? "enabled" : "disabled");
37
182
  });
38
183
  }
39
184
  function wrapDetailsContent() {
@@ -67,5 +212,6 @@ function wrapDetailsContent() {
67
212
  export {
68
213
  hideSingleLineGutters,
69
214
  syncTocLabelsFromHeadings,
215
+ tabbedH2Content,
70
216
  wrapDetailsContent
71
217
  };
@@ -7,6 +7,12 @@ interface DomPatchesOptions {
7
7
  syncTocLabelsFromHeadings?: boolean;
8
8
  /** Wrap `<details>` content (excluding `<summary>`) in a `.details-wrapper` div. @default false */
9
9
  wrapDetailsContent?: boolean;
10
+ /**
11
+ * Inject a toggle checkbox (after `#starlight__on-this-page`) that reorganises page content
12
+ * into tabs driven by `<h2>` boundaries. Toggle state is persisted to localStorage.
13
+ * @default false
14
+ */
15
+ offerTabbedContent?: boolean;
10
16
  }
11
17
  /**
12
18
  * Astro integration that injects a client-side script to apply DOM patches
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  starlightDomPatches
3
- } from "../chunk-XMGAWBJ5.js";
3
+ } from "../chunk-OXJMUUGP.js";
4
4
  import "../chunk-4VNS5WPM.js";
5
5
  export {
6
6
  starlightDomPatches
@@ -133,6 +133,55 @@ div > div[class="page"] > svg {
133
133
  }
134
134
  }
135
135
 
136
+ /********** Tabbed H2 Content **********/
137
+ .tabbed-content-nav {
138
+ display: flex;
139
+ flex-wrap: wrap;
140
+ gap: 0.375rem;
141
+ margin-bottom: 1.75rem;
142
+ max-height: 9rem;
143
+ overflow-y: auto;
144
+ }
145
+
146
+ .tabbed-content-tab {
147
+ padding: 0.3rem 0.85rem;
148
+ border: none;
149
+ border-radius: 9999px;
150
+ background-color: var(--sl-color-gray-6);
151
+ color: var(--sl-color-gray-3);
152
+ cursor: pointer;
153
+ font-size: 0.8125em;
154
+ font-weight: 500;
155
+ transition:
156
+ color 0.15s ease,
157
+ background-color 0.15s ease;
158
+
159
+ &:hover {
160
+ background-color: var(--sl-color-gray-5);
161
+ color: var(--sl-color-text);
162
+ }
163
+
164
+ &[data-active="true"] {
165
+ background-color: var(--sl-color-accent-high);
166
+ color: var(--sl-color-black);
167
+ }
168
+
169
+ html[data-theme="light"] & {
170
+ background-color: var(--sl-color-gray-7);
171
+ color: var(--sl-color-gray-4);
172
+
173
+ &:hover {
174
+ background-color: var(--sl-color-gray-6);
175
+ color: var(--sl-color-gray-2);
176
+ }
177
+
178
+ &[data-active="true"] {
179
+ background-color: var(--sl-color-accent-low);
180
+ color: var(--sl-color-white);
181
+ }
182
+ }
183
+ }
184
+
136
185
  /********** Toggle Checkbox Styles **********/
137
186
  #toggle-all-details-btn {
138
187
  display: flex;
@@ -167,6 +216,39 @@ div > div[class="page"] > svg {
167
216
  }
168
217
  }
169
218
 
219
+ .toggle-checkbox-btn {
220
+ display: flex;
221
+ align-items: center;
222
+ gap: 0.5rem;
223
+ cursor: pointer;
224
+
225
+ input[type="checkbox"] {
226
+ position: relative;
227
+ appearance: none;
228
+ width: 1.2em;
229
+ height: 1.2em;
230
+ border: 2px solid currentColor;
231
+ border-radius: 2px;
232
+ cursor: pointer;
233
+ display: flex;
234
+ align-items: center;
235
+ justify-content: center;
236
+ transition: background-color 0.2s ease;
237
+
238
+ &:checked {
239
+ background-color: var(--sl-color-accent);
240
+ border-color: var(--sl-color-accent);
241
+
242
+ &::after {
243
+ content: "✓";
244
+ color: white;
245
+ font-size: 0.8em;
246
+ font-weight: bold;
247
+ }
248
+ }
249
+ }
250
+ }
251
+
170
252
  // invert img.note-svg when on dark mode
171
253
  img.note-svg {
172
254
  padding-top: 1em;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "starlight-cannoli-plugins",
3
3
  "type": "module",
4
- "version": "2.7.0",
4
+ "version": "2.8.0",
5
5
  "description": "Starlight plugins for automatic sidebar generation and link validation",
6
6
  "license": "ISC",
7
7
  "main": "./dist/index.js",