@classic-homes/theme-docs 0.2.0 → 0.3.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.
@@ -6,7 +6,7 @@
6
6
  * Pass a `CategoryPage` from `loadDocs`. Page cards show `descriptions[route]` when
7
7
  * given; category cards show how many entries they hold.
8
8
  */
9
- import { PageHeader } from '@classic-homes/theme-svelte';
9
+ import { ActionHeader } from '@classic-homes/theme-svelte';
10
10
  import DocsHub from './DocsHub.svelte';
11
11
  import { countPages, flattenNav } from '../content/nav.js';
12
12
  import type { CategoryPage } from '../content/types.js';
@@ -46,6 +46,6 @@
46
46
  </script>
47
47
 
48
48
  <div class={className}>
49
- <PageHeader class="mb-8" title={category.title} subtitle={category.description || undefined} />
49
+ <ActionHeader class="mb-6" title={category.title} subtitle={category.description || undefined} />
50
50
  <DocsHub {columns} config={{ sections: [{ id: 'items', items }] }} />
51
51
  </div>
@@ -3,13 +3,13 @@
3
3
  * DocPage - one docs page, laid out the way Docusaurus lays out a doc.
4
4
  *
5
5
  * Takes HTML rendered at build time (`renderDocs`), so it renders on the server with no
6
- * client-side parsing. Around the content: breadcrumbs, a `<h1>` from the title when the
7
- * body has none, tags, an edit link, previous/next links and the table of contents. In
6
+ * client-side parsing. Around the content: breadcrumbs, the title as an `ActionHeader`
7
+ * (like app pages) when the body has no `<h1>`, tags, an edit link, previous/next links and the table of contents. In
8
8
  * the browser it mounts allowlisted components, renders mermaid diagrams, adds copy
9
9
  * buttons to code blocks and highlights `?highlight=` search terms.
10
10
  */
11
11
  import type { Component, Snippet } from 'svelte';
12
- import { Button } from '@classic-homes/theme-svelte';
12
+ import { ActionHeader, Button } from '@classic-homes/theme-svelte';
13
13
  import { cn } from '../utils.js';
14
14
  import Breadcrumbs from './Breadcrumbs.svelte';
15
15
  import DocPager from './DocPager.svelte';
@@ -22,7 +22,9 @@
22
22
  import type { RenderedDoc, Tag } from '../content/types.js';
23
23
 
24
24
  interface Props {
25
- page: Pick<RenderedDoc, 'title' | 'html' | 'toc' | 'hasH1'> & { description?: string };
25
+ page: Pick<RenderedDoc, 'title' | 'heading' | 'html' | 'toc' | 'hasH1'> & {
26
+ description?: string;
27
+ };
26
28
  /** `breadcrumbs(sidebar.items, route)` */
27
29
  breadcrumbs?: Crumb[];
28
30
  /** First breadcrumb, e.g. `{ label: 'Docs', href: '/' }` */
@@ -39,6 +41,8 @@
39
41
  components?: Record<string, Component<any>>;
40
42
  /** Render mermaid diagrams (needs the optional `mermaid` peer). Default: true */
41
43
  mermaid?: boolean;
44
+ /** Mermaid theme. Default: `brand` (colours from the theme tokens) */
45
+ mermaidTheme?: 'brand' | 'default' | 'dark' | 'forest' | 'neutral';
42
46
  /** Copy buttons on code blocks. Default: true */
43
47
  copyButtons?: boolean;
44
48
  /** Query parameter whose words are highlighted in the page. Default: `highlight` */
@@ -74,6 +78,7 @@
74
78
  editLabel = 'Edit this page',
75
79
  components,
76
80
  mermaid = true,
81
+ mermaidTheme = 'brand',
77
82
  copyButtons = true,
78
83
  highlightParam = 'highlight',
79
84
  tocMinDepth = 2,
@@ -125,14 +130,16 @@
125
130
  <div class="min-w-0 max-w-3xl flex-1">
126
131
  <Breadcrumbs crumbs={breadcrumbs} {home} />
127
132
 
133
+ <!-- The title as app pages show theirs (ActionHeader); render with `hoistTitle` so
134
+ the body's own `# Title` becomes this rather than a markdown-styled h1. -->
135
+ {#if !page.hasH1}<ActionHeader class="mb-6" title={page.heading ?? page.title} />{/if}
128
136
  <article class="markdown-content">
129
- {#if !page.hasH1}<h1>{page.title}</h1>{/if}
130
137
  {@render header?.()}
131
138
  {#key page.html}
132
139
  <div bind:this={content}>{@html page.html}</div>
133
140
  {/key}
134
141
  </article>
135
- {#if mermaid}<MermaidInit watch />{/if}
142
+ {#if mermaid}<MermaidInit watch theme={mermaidTheme} />{/if}
136
143
 
137
144
  <footer class="mt-12 space-y-6">
138
145
  {#if tags.length}<TagList {tags} href={tagHref} />{/if}
@@ -2,8 +2,8 @@
2
2
  * DocPage - one docs page, laid out the way Docusaurus lays out a doc.
3
3
  *
4
4
  * Takes HTML rendered at build time (`renderDocs`), so it renders on the server with no
5
- * client-side parsing. Around the content: breadcrumbs, a `<h1>` from the title when the
6
- * body has none, tags, an edit link, previous/next links and the table of contents. In
5
+ * client-side parsing. Around the content: breadcrumbs, the title as an `ActionHeader`
6
+ * (like app pages) when the body has no `<h1>`, tags, an edit link, previous/next links and the table of contents. In
7
7
  * the browser it mounts allowlisted components, renders mermaid diagrams, adds copy
8
8
  * buttons to code blocks and highlights `?highlight=` search terms.
9
9
  */
@@ -11,7 +11,7 @@ import type { Component, Snippet } from 'svelte';
11
11
  import type { Crumb, NavLink } from '../content/nav.js';
12
12
  import type { RenderedDoc, Tag } from '../content/types.js';
13
13
  interface Props {
14
- page: Pick<RenderedDoc, 'title' | 'html' | 'toc' | 'hasH1'> & {
14
+ page: Pick<RenderedDoc, 'title' | 'heading' | 'html' | 'toc' | 'hasH1'> & {
15
15
  description?: string;
16
16
  };
17
17
  /** `breadcrumbs(sidebar.items, route)` */
@@ -33,6 +33,8 @@ interface Props {
33
33
  components?: Record<string, Component<any>>;
34
34
  /** Render mermaid diagrams (needs the optional `mermaid` peer). Default: true */
35
35
  mermaid?: boolean;
36
+ /** Mermaid theme. Default: `brand` (colours from the theme tokens) */
37
+ mermaidTheme?: 'brand' | 'default' | 'dark' | 'forest' | 'neutral';
36
38
  /** Copy buttons on code blocks. Default: true */
37
39
  copyButtons?: boolean;
38
40
  /** Query parameter whose words are highlighted in the page. Default: `highlight` */
@@ -12,8 +12,11 @@
12
12
  import { onMount } from 'svelte';
13
13
 
14
14
  interface Props {
15
- /** Mermaid theme (default: 'default') */
16
- theme?: 'default' | 'dark' | 'forest' | 'neutral';
15
+ /**
16
+ * Mermaid theme (default: 'default'). `brand` colours diagrams from the theme tokens
17
+ * on the page (muted node fills, foreground text and lines, Figtree).
18
+ */
19
+ theme?: 'default' | 'dark' | 'forest' | 'neutral' | 'brand';
17
20
  /** Whether to watch for new diagrams via MutationObserver */
18
21
  watch?: boolean;
19
22
  }
@@ -22,6 +25,42 @@
22
25
 
23
26
  let initialized = $state(false);
24
27
 
28
+ /** A token's HSL triplet (`357 72% 39%`) as a colour mermaid's colour math can parse */
29
+ function token(styles: CSSStyleDeclaration, name: string, fallback: string): string {
30
+ const parts = styles.getPropertyValue(`--${name}`).trim().split(/\s+/);
31
+ return parts.length === 3 ? `hsl(${parts.join(', ')})` : fallback;
32
+ }
33
+
34
+ function brandConfig() {
35
+ const s = getComputedStyle(document.documentElement);
36
+ const foreground = token(s, 'foreground', '#4f4f4f');
37
+ return {
38
+ theme: 'base' as const,
39
+ themeVariables: {
40
+ fontFamily: 'Figtree, ui-sans-serif, system-ui, sans-serif',
41
+ fontSize: '14px',
42
+ background: token(s, 'background', '#ffffff'),
43
+ primaryColor: token(s, 'muted', '#e8e9e4'),
44
+ primaryTextColor: foreground,
45
+ primaryBorderColor: token(s, 'secondary', '#5f6150'),
46
+ secondaryColor: token(s, 'sidebar-background', '#f5f5f3'),
47
+ secondaryTextColor: foreground,
48
+ secondaryBorderColor: token(s, 'outline', '#8b8e7b'),
49
+ tertiaryColor: token(s, 'content-bg', '#fafafa'),
50
+ tertiaryTextColor: foreground,
51
+ tertiaryBorderColor: token(s, 'outline', '#8b8e7b'),
52
+ clusterBkg: token(s, 'content-bg', '#fafafa'),
53
+ clusterBorder: token(s, 'outline', '#8b8e7b'),
54
+ lineColor: token(s, 'muted-foreground', '#666666'),
55
+ textColor: foreground,
56
+ noteBkgColor: token(s, 'muted', '#e8e9e4'),
57
+ noteTextColor: foreground,
58
+ noteBorderColor: token(s, 'outline', '#8b8e7b'),
59
+ edgeLabelBackground: token(s, 'background', '#ffffff'),
60
+ },
61
+ };
62
+ }
63
+
25
64
  async function renderDiagrams() {
26
65
  try {
27
66
  const mermaid = await import('mermaid');
@@ -29,7 +68,7 @@
29
68
  // Initialize mermaid
30
69
  mermaid.default.initialize({
31
70
  startOnLoad: false,
32
- theme: theme,
71
+ ...(theme === 'brand' ? brandConfig() : { theme }),
33
72
  securityLevel: 'strict',
34
73
  });
35
74
 
@@ -1,6 +1,9 @@
1
1
  interface Props {
2
- /** Mermaid theme (default: 'default') */
3
- theme?: 'default' | 'dark' | 'forest' | 'neutral';
2
+ /**
3
+ * Mermaid theme (default: 'default'). `brand` colours diagrams from the theme tokens
4
+ * on the page (muted node fills, foreground text and lines, Figtree).
5
+ */
6
+ theme?: 'default' | 'dark' | 'forest' | 'neutral' | 'brand';
4
7
  /** Whether to watch for new diagrams via MutationObserver */
5
8
  watch?: boolean;
6
9
  }
@@ -13,8 +13,8 @@ export interface RenderDocsOptions {
13
13
  onBrokenAnchors?: BrokenLinkPolicy;
14
14
  /**
15
15
  * Lift a page's leading `# Heading` out of the body: it becomes the title when the page
16
- * has no `title` front matter, and the layout renders it (so content such as a lead
17
- * paragraph can sit between title and body). Default: false
16
+ * has no `title` front matter (else `heading`, when it differs), and the layout renders
17
+ * it (so content such as a lead paragraph can sit between title and body). Default: false
18
18
  */
19
19
  hoistTitle?: boolean;
20
20
  }
@@ -13,13 +13,17 @@ export async function renderDocs(docs, options = {}) {
13
13
  const rendered = await parseMarkdown(markdown, parse);
14
14
  let html = rendered.html;
15
15
  let { title } = page;
16
+ let heading;
16
17
  let toc = rendered.toc;
17
18
  if (hoistTitle) {
18
19
  const leading = /^\s*<h1\b[^>]*>([\s\S]*?)<\/h1>\s*/.exec(html);
19
20
  if (leading) {
20
21
  html = html.slice(leading[0].length);
22
+ const text = plainText(leading[1]);
21
23
  if (page.frontmatter.title === undefined)
22
- title = plainText(leading[1]);
24
+ title = text;
25
+ else if (text && text !== title)
26
+ heading = text;
23
27
  toc = toc.filter((entry, i) => !(i === 0 && entry.level === 1));
24
28
  }
25
29
  }
@@ -32,6 +36,7 @@ export async function renderDocs(docs, options = {}) {
32
36
  return {
33
37
  ...page,
34
38
  title,
39
+ ...(heading && { heading }),
35
40
  html,
36
41
  toc,
37
42
  hasH1: toc.some((entry) => entry.level === 1) || /<h1[\s>]/.test(html),
@@ -85,6 +85,12 @@ export interface RenderedDoc extends Omit<DocSource, 'markdown'> {
85
85
  toc: TocEntry[];
86
86
  /** Whether the body has its own `<h1>`. When it doesn't, the layout shows `title`. */
87
87
  hasH1: boolean;
88
+ /**
89
+ * With `hoistTitle`: the text of the `# Heading` lifted out of the body, when it differs
90
+ * from `title`. Docusaurus shows the body heading on the page and keeps `title` for the
91
+ * sidebar and `<title>`, so layouts show this in place of `title` when set.
92
+ */
93
+ heading?: string;
88
94
  /** Searchable plain text: the page without markup, code blocks or diagrams */
89
95
  text: string;
90
96
  }
@@ -52,6 +52,6 @@ export interface SearchQueryOptions {
52
52
  export declare function createSearch(indexJson: string | object, texts?: Map<string, string> | Record<string, string>): {
53
53
  search(query: string, options?: SearchQueryOptions): SearchHit[];
54
54
  };
55
- /** About 160 characters around the first matched term. */
55
+ /** About 160 characters around the first matched term, cut at word boundaries. */
56
56
  export declare function snippet(text: string, terms: string[]): string;
57
57
  export {};
@@ -62,7 +62,7 @@ export function createSearch(indexJson, texts) {
62
62
  title: stored.title,
63
63
  description: stored.description,
64
64
  sidebar: stored.sidebar,
65
- snippet: snippet(textOf(route) ?? stored.description ?? '', hit.terms),
65
+ snippet: snippet(withoutTitle(textOf(route), stored.title) ?? stored.description ?? '', hit.terms),
66
66
  terms: hit.terms,
67
67
  score: hit.score,
68
68
  };
@@ -70,13 +70,38 @@ export function createSearch(indexJson, texts) {
70
70
  },
71
71
  };
72
72
  }
73
- /** About 160 characters around the first matched term. */
73
+ /**
74
+ * A page's text minus its leading title: the body's `# Title` heading is the first thing in
75
+ * the text, and the result already shows the title, so a snippet would repeat it.
76
+ */
77
+ function withoutTitle(text, title) {
78
+ if (!text || !title || !text.startsWith(title))
79
+ return text;
80
+ return text.slice(title.length).trimStart();
81
+ }
82
+ /** About 160 characters around the first matched term, cut at word boundaries. */
74
83
  export function snippet(text, terms) {
75
84
  const lower = text.toLowerCase();
76
85
  const at = Math.min(...terms.map((term) => lower.indexOf(term.toLowerCase())).filter((i) => i >= 0));
77
86
  if (!Number.isFinite(at))
78
- return text.length > 160 ? `${text.slice(0, 160).trim()}…` : text;
79
- const start = Math.max(0, at - 60);
80
- const end = start + 160;
87
+ return clip(text, 0, 160);
88
+ return clip(text, Math.max(0, at - 60), Math.max(0, at - 60) + 160, at);
89
+ }
90
+ /** `text[start, end)` widened or narrowed to whole words (never past `keep`), with ellipses. */
91
+ function clip(text, start, end, keep = start) {
92
+ if (start > 0) {
93
+ // Start after the space before the cut, so the snippet opens on a whole word.
94
+ const space = text.indexOf(' ', start);
95
+ if (space >= 0 && space < keep)
96
+ start = space + 1;
97
+ }
98
+ if (end < text.length) {
99
+ const space = text.lastIndexOf(' ', end);
100
+ if (space > keep)
101
+ end = space;
102
+ }
103
+ else {
104
+ end = text.length;
105
+ }
81
106
  return `${start > 0 ? '…' : ''}${text.slice(start, end).trim()}${end < text.length ? '…' : ''}`;
82
107
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/theme-docs",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Markdown documentation components for the Classic theme system",
5
5
  "type": "module",
6
6
  "main": "./dist/lib/index.js",