@writedocs/generator 0.4.6 → 0.4.7

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.
@@ -34,6 +34,11 @@ const BUILTIN_COMPONENT_NAMES = [
34
34
  'Icon',
35
35
  'RequestExample',
36
36
  'ResponseExample',
37
+ 'Check',
38
+ 'ParamField',
39
+ 'ResponseField',
40
+ 'Columns',
41
+ 'Tooltip',
37
42
  ];
38
43
 
39
44
  const PACKAGE_SPECIFIER = 'writedocs/components';
@@ -8,7 +8,7 @@ import { visit } from 'unist-util-visit';
8
8
  // (Card, Tabs, AccordionGroup, etc. don't). AccordionGroup is deliberately
9
9
  // excluded - it has no `title` of its own, just wraps Accordion children
10
10
  // that each get their own entry here independently.
11
- const TITLED_COMPONENT_NAMES = ['Callout', 'Note', 'Info', 'Tip', 'Warning', 'Danger', 'Accordion'];
11
+ const TITLED_COMPONENT_NAMES = ['Callout', 'Note', 'Info', 'Tip', 'Warning', 'Danger', 'Check', 'Accordion'];
12
12
 
13
13
  // Deliberately the same slugify Callout.astro/Accordion.astro themselves
14
14
  // fall back to (see their own comments) - kept as a literal duplicate
@@ -21,33 +21,124 @@
21
21
  // at all, so this only ever touches genuine ```-fenced MDX content and
22
22
  // never collides with the API playground's own bespoke copy buttons.
23
23
 
24
- const META_TITLE_RE = /title\s*=\s*(["'])((?:(?!\1).)*)\1/;
24
+ import { iconSvg } from './config.ts';
25
25
 
26
- function parseMeta(raw) {
27
- return {
28
- title: raw.match(META_TITLE_RE)?.[2] ?? null,
29
- wrap: /\bwrap\b/.test(raw),
30
- lines: /\b(?:lines|showLineNumbers)\b/.test(raw),
31
- expandable: /\bexpandable\b/.test(raw),
26
+ // The fence's info string after the language, split into tokens: a
27
+ // `key=value` option (value quoted, in braces, or bare), a bare `{1,3-5}`
28
+ // range, a `/word/` pattern, or any other bare word.
29
+ const META_TOKEN_RE = /([A-Za-z][\w-]*)=(?:"([^"]*)"|'([^']*)'|\{([^}]*)\}|(\S+))|\{([^}]*)\}|(\/(?:\\.|[^/\s])+\/)(?=\s|$)|(\S+)/g;
30
+
31
+ // Bare words that are options, not title text. Case-sensitive on purpose:
32
+ // Mintlify's own docs write ```js Wrap example wrap, where the capitalized
33
+ // "Wrap" is part of the title and only the trailing "wrap" is the flag.
34
+ const META_FLAGS = new Set(['wrap', 'lines', 'showLineNumbers', 'expandable', 'nocopy', 'twoslash']);
35
+
36
+ function parseRanges(value) {
37
+ return value
38
+ .split(',')
39
+ .map((part) => part.trim())
40
+ .filter(Boolean)
41
+ .flatMap((part) => {
42
+ const [start, end] = part.split('-').map((n) => Number.parseInt(n, 10));
43
+ if (Number.isNaN(start)) return [];
44
+ if (end === undefined || Number.isNaN(end)) return [start];
45
+ return Array.from({ length: end - start + 1 }, (_, i) => start + i);
46
+ });
47
+ }
48
+
49
+ /** Parses a fence's info string in both syntaxes writedocs accepts:
50
+ * its own (```js title="a.js" {1,3} wrap) and Mintlify's
51
+ * (```js a.js highlight={1,3} focus={2} icon="js" nocopy wrap) - the
52
+ * Mintlify title is the run of plain words right after the language,
53
+ * before the first option. `title="..."` wins over an inline title.
54
+ *
55
+ * `shikiMeta` is what the stock @shikijs/transformers meta transformers
56
+ * get to see instead of the raw string (see preprocess below): only the
57
+ * highlight range and the /word/ patterns. Read raw, those transformers
58
+ * would take focus={2} as a highlight range (they grab the first {...}
59
+ * anywhere) and a / inside a title or path as a word to highlight. */
60
+ export function parseMeta(raw) {
61
+ const meta = {
62
+ title: null,
63
+ icon: null,
64
+ wrap: false,
65
+ lines: false,
66
+ expandable: false,
67
+ nocopy: false,
68
+ twoslash: false,
69
+ highlight: [],
70
+ focus: [],
71
+ words: [],
32
72
  };
73
+ const titleWords = [];
74
+ let sawOption = false;
75
+ for (const m of raw.matchAll(META_TOKEN_RE)) {
76
+ const [, key, dq, sq, braced, bare, bareRange, word, other] = m;
77
+ if (key !== undefined) {
78
+ sawOption = true;
79
+ const value = dq ?? sq ?? braced ?? bare ?? '';
80
+ if (key === 'title') meta.title = value;
81
+ else if (key === 'icon') meta.icon = value;
82
+ else if (key === 'highlight') meta.highlight.push(...parseRanges(value));
83
+ else if (key === 'focus') meta.focus.push(...parseRanges(value));
84
+ // Mintlify's `nocopy="false"` (explicitly keep the copy button).
85
+ else if (META_FLAGS.has(key)) meta[key === 'showLineNumbers' ? 'lines' : key] = value !== 'false';
86
+ continue;
87
+ }
88
+ if (bareRange !== undefined) {
89
+ sawOption = true;
90
+ meta.highlight.push(...parseRanges(bareRange));
91
+ continue;
92
+ }
93
+ if (word !== undefined) {
94
+ sawOption = true;
95
+ meta.words.push(word);
96
+ continue;
97
+ }
98
+ if (META_FLAGS.has(other)) {
99
+ sawOption = true;
100
+ meta[other === 'showLineNumbers' ? 'lines' : other] = true;
101
+ continue;
102
+ }
103
+ if (!sawOption) titleWords.push(other);
104
+ }
105
+ if (meta.title === null && titleWords.length > 0) meta.title = titleWords.join(' ');
106
+ const shikiParts = [];
107
+ if (meta.highlight.length > 0) shikiParts.push(`{${meta.highlight.join(',')}}`);
108
+ shikiParts.push(...meta.words);
109
+ meta.shikiMeta = shikiParts.join(' ');
110
+ return meta;
33
111
  }
34
112
 
35
113
  export function codeBlockTransformer() {
36
114
  return {
37
115
  name: 'writedocs:code-block',
38
- // Runs once per block, before `root` - stashes the parsed meta
39
- // flags on `this` (the shared per-block transformer context; see
40
- // @shikijs/core's tokensToHast, which calls both `pre` and `root`
41
- // with the same `context` object for a given block, but a *fresh*
42
- // one for each block) so `root` below doesn't have to re-parse it.
116
+ // preprocess runs before every other hook of every transformer (see
117
+ // @shikijs/core's codeToTokens), so this is where the raw info string
118
+ // is parsed once and swapped for the cleaned-up `shikiMeta` the stock
119
+ // meta-highlight transformers read - see parseMeta() above. The full
120
+ // parse is kept on the same options object for the hooks below.
121
+ preprocess(code, options) {
122
+ if (options.meta) {
123
+ options.meta.__wdMeta = parseMeta(options.meta.__raw ?? '');
124
+ options.meta.__raw = options.meta.__wdMeta.shikiMeta;
125
+ }
126
+ return code;
127
+ },
128
+ line(node, lineNumber) {
129
+ if (this.options.meta?.__wdMeta?.focus.includes(lineNumber)) this.addClassToHast(node, 'focused');
130
+ return node;
131
+ },
132
+ // Same classes transformerNotationFocus (`// [!code focus]`) adds, so
133
+ // `focus={...}` reuses [...slug].astro's existing dimming CSS.
43
134
  pre(node) {
44
- this.wdMeta = parseMeta(this.options.meta?.__raw ?? '');
135
+ if (this.options.meta?.__wdMeta?.focus.length) this.addClassToHast(node, 'has-focused');
45
136
  return node;
46
137
  },
47
138
  root(hast) {
48
139
  const pre = hast.children.find((child) => child.type === 'element' && child.tagName === 'pre');
49
140
  if (!pre) return hast;
50
- const meta = this.wdMeta ?? parseMeta(this.options.meta?.__raw ?? '');
141
+ const meta = this.options.meta?.__wdMeta ?? parseMeta(this.options.meta?.__raw ?? '');
51
142
 
52
143
  const wrapperClass = ['wd-code-block'];
53
144
  if (meta.wrap) wrapperClass.push('wd-code-wrap');
@@ -56,25 +147,48 @@ export function codeBlockTransformer() {
56
147
 
57
148
  const children = [];
58
149
  if (meta.title) {
150
+ const titleChildren = [];
151
+ // Drawn as a CSS mask over currentColor, so the icon takes the
152
+ // title bar's own text color in both themes. An icon string that
153
+ // isn't an installed icon (emoji, unknown name) is skipped rather
154
+ // than failing the build.
155
+ const svg = meta.icon ? iconSvg(meta.icon) : null;
156
+ if (svg) {
157
+ titleChildren.push({
158
+ type: 'element',
159
+ tagName: 'span',
160
+ properties: {
161
+ class: 'wd-code-title-icon',
162
+ 'aria-hidden': 'true',
163
+ style: `--wd-code-icon: url("data:image/svg+xml,${encodeURIComponent(svg)}")`,
164
+ },
165
+ children: [],
166
+ });
167
+ }
168
+ titleChildren.push({ type: 'text', value: meta.title });
59
169
  children.push({
60
170
  type: 'element',
61
171
  tagName: 'div',
62
172
  properties: { class: 'wd-code-title' },
63
- children: [{ type: 'text', value: meta.title }],
173
+ children: titleChildren,
64
174
  });
65
175
  }
66
176
  children.push(pre);
67
- children.push({
68
- type: 'element',
69
- tagName: 'button',
70
- properties: {
71
- type: 'button',
72
- class: 'wd-code-copy-btn',
73
- 'data-role': 'copy-code',
74
- 'aria-label': 'Copy code',
75
- },
76
- children: [{ type: 'text', value: '⧉' }],
77
- });
177
+ // Mintlify's `nocopy` - for content where copying makes no sense
178
+ // (ASCII diagrams, sample output).
179
+ if (!meta.nocopy) {
180
+ children.push({
181
+ type: 'element',
182
+ tagName: 'button',
183
+ properties: {
184
+ type: 'button',
185
+ class: 'wd-code-copy-btn',
186
+ 'data-role': 'copy-code',
187
+ 'aria-label': 'Copy code',
188
+ },
189
+ children: [{ type: 'text', value: '⧉' }],
190
+ });
191
+ }
78
192
  if (meta.expandable) {
79
193
  children.push({
80
194
  type: 'element',
@@ -51,6 +51,11 @@ import Badge from "../components/Badge.astro";
51
51
  import Icon from "../components/Icon.astro";
52
52
  import RequestExample from "../components/RequestExample.astro";
53
53
  import ResponseExample from "../components/ResponseExample.astro";
54
+ import Check from "../components/Check.astro";
55
+ import ParamField from "../components/ParamField.astro";
56
+ import ResponseField from "../components/ResponseField.astro";
57
+ import Columns from "../components/Columns.astro";
58
+ import Tooltip from "../components/Tooltip.astro";
54
59
  import ApiPlayground from "../components/ApiPlayground.astro";
55
60
  import ApiReferencePanel from "../components/ApiReferencePanel.astro";
56
61
 
@@ -156,6 +161,15 @@ export async function getStaticPaths() {
156
161
 
157
162
  const pageRoutes = sections.flatMap((section, sectionIndex) => {
158
163
  const flatNav = flattenNav(section.pages);
164
+ // Prev/next skip pages that aren't in the sidebar (frontmatter
165
+ // `hidden`) or aren't pages at all (frontmatter `url`, an external
166
+ // link) - the reader should land on the next page they could also
167
+ // have clicked in the sidebar. Inlined for the same reason as the
168
+ // error branch below.
169
+ const isPaginationStop = (slug: string) => {
170
+ const data = entryByFileId.get(slug)?.data;
171
+ return Boolean(data) && !data!.hidden && !data!.url;
172
+ };
159
173
  return flatNav.map((navEntry, i) => {
160
174
  const entry = entryByFileId.get(navEntry.slug);
161
175
  if (!entry) {
@@ -184,9 +198,17 @@ export async function getStaticPaths() {
184
198
  );
185
199
  }
186
200
  claimedFileIds.add(navEntry.slug);
187
- const prev = i > 0 ? flatNav[i - 1] : null;
188
- const next = i < flatNav.length - 1 ? flatNav[i + 1] : null;
201
+ const prev = flatNav.slice(0, i).findLast((e) => isPaginationStop(e.slug)) ?? null;
202
+ const next = flatNav.slice(i + 1).find((e) => isPaginationStop(e.slug)) ?? null;
189
203
  const urlSlug = normalizeEntryId(entry.id);
204
+ // A frontmatter `url` page (Mintlify's external link) exists only
205
+ // to put that link in the sidebar - its own URL redirects there.
206
+ if (entry.data.url) {
207
+ return {
208
+ params: { slug: urlSlug === "index" ? undefined : urlSlug },
209
+ props: { redirectTo: entry.data.url },
210
+ };
211
+ }
190
212
  return {
191
213
  params: { slug: urlSlug === "index" ? undefined : urlSlug },
192
214
  props: { entry, prev, next, sectionIndex },
@@ -276,6 +298,9 @@ const entries: DocsEntry[] = [...pagesEntries, ...generatedDocsEntries];
276
298
  // themselves so hrefForSlug can resolve a writedocs.json file-id reference to
277
299
  // wherever that page actually got routed.
278
300
  const titleByFileId = new Map<string, string>();
301
+ // Navigation labels (sidebar, topbar dropdowns): a page's `sidebarTitle`
302
+ // when it has one - Mintlify's short nav label - else its `title`.
303
+ const navTitleByFileId = new Map<string, string>();
279
304
  const entryByFileId = new Map<string, DocsEntry>();
280
305
  // The HTTP method badge NavTree.astro shows next to an API operation
281
306
  // page in the sidebar - read straight off the page's own `openapi:
@@ -286,13 +311,19 @@ const methodByFileId = new Map<string, string>();
286
311
  for (const e of entries) {
287
312
  const fileId = fileIdForEntry(contentDir, packageRoot, e);
288
313
  titleByFileId.set(fileId, e.data.title);
314
+ navTitleByFileId.set(fileId, e.data.sidebarTitle ?? e.data.title);
289
315
  entryByFileId.set(fileId, e);
290
- if (e.data.openapi) {
316
+ // `hideApiMarker` (Mintlify's) drops the badge for this one page.
317
+ if (e.data.openapi && !e.data.hideApiMarker) {
291
318
  methodByFileId.set(fileId, e.data.openapi.trim().split(/\s+/, 1)[0].toUpperCase());
292
319
  }
293
320
  }
294
321
  const titleForSlug = (slug: string) => titleByFileId.get(slug) ?? slug;
322
+ const navTitleForSlug = (slug: string) => navTitleByFileId.get(slug) ?? slug;
295
323
  const methodForSlug = (slug: string) => methodByFileId.get(slug) ?? null;
324
+ // Frontmatter that changes how a page appears in navigation (hidden, url,
325
+ // icon, tag, deprecated) - see buildNavTree() in lib/config.ts.
326
+ const navMetaForSlug = (slug: string) => entryByFileId.get(slug)?.data;
296
327
  const hrefForSlug = (slug: string) => {
297
328
  const raw = entryByFileId.get(slug)?.id ?? slug;
298
329
  const effective = normalizeEntryId(raw);
@@ -303,7 +334,7 @@ const hrefForSlug = (slug: string) => {
303
334
  // and what every "is this the active page" comparison below needs, since
304
335
  // navTree/globalDropdowns are built from writedocs.json's file-id space.
305
336
  const currentFileId = fileIdForEntry(contentDir, packageRoot, entry);
306
- const navTree = buildNavTree(activeSection.pages, titleForSlug, methodForSlug);
337
+ const navTree = buildNavTree(activeSection.pages, navTitleForSlug, methodForSlug, navMetaForSlug);
307
338
  // The ancestor sidebar-group trail above the auto <h1> (Breadcrumbs.astro)
308
339
  // - empty for a page sitting at the top level of its Section with no
309
340
  // enclosing group, in which case [...slug].astro below skips rendering
@@ -326,8 +357,9 @@ const selectors = buildSelectors(activeSection.path, hrefForSlug, activePagePosi
326
357
  const globalDropdowns = buildGlobalDropdowns(
327
358
  resolveGlobalDropdowns(config.navigation),
328
359
  currentFileId,
329
- titleForSlug,
360
+ navTitleForSlug,
330
361
  hrefForSlug,
362
+ navMetaForSlug,
331
363
  );
332
364
 
333
365
  // Meta tags: this page's own frontmatter `seo` overrides writedocs.json's
@@ -401,6 +433,11 @@ const components = {
401
433
  Icon,
402
434
  RequestExample,
403
435
  ResponseExample,
436
+ Check,
437
+ ParamField,
438
+ ResponseField,
439
+ Columns,
440
+ Tooltip,
404
441
  };
405
442
  ---
406
443
 
@@ -430,13 +467,25 @@ const components = {
430
467
  <article class={`wd-article ${pageMode === "wide" ? "wd-article-wide" : ""}`} data-pagefind-body>
431
468
  {breadcrumbs.length > 0 && <Breadcrumbs items={breadcrumbs} />}
432
469
  <div class="wd-article-header">
433
- <h1 data-pagefind-meta="title">{entry.data.title}</h1>
470
+ {
471
+ // Frontmatter `deprecated` puts a label beside the <h1>, not in
472
+ // it - the <h1>'s text is the search index's page title.
473
+ entry.data.deprecated ? (
474
+ <div class="wd-article-title">
475
+ <h1 data-pagefind-meta="title">{entry.data.title}</h1>
476
+ <span class="wd-deprecated-badge">Deprecated</span>
477
+ </div>
478
+ ) : (
479
+ <h1 data-pagefind-meta="title">{entry.data.title}</h1>
480
+ )
481
+ }
434
482
  {showCopyPageMenu && (
435
483
  <CopyPageMenu currentPath={currentPath} siteUrl={siteUrl} contextMenu={config.contextMenu!} />
436
484
  )}
437
485
  </div>
438
486
  <Content components={components} />
439
487
  {entry.data.openapi && <ApiPlayground operation={entry.data.openapi} contentDir={contentDir} />}
488
+ {!entry.data.hideFooterPagination && (
440
489
  <nav class="wd-prevnext" data-pagefind-ignore>
441
490
  {prev && (
442
491
  <a class="wd-prevnext-card wd-prevnext-prev" href={hrefForSlug(prev.slug)}>
@@ -485,6 +534,7 @@ const components = {
485
534
  </a>
486
535
  )}
487
536
  </nav>
537
+ )}
488
538
  </article>
489
539
  )
490
540
  }
@@ -777,6 +827,24 @@ const components = {
777
827
  .wd-article-header h1 {
778
828
  margin: 0;
779
829
  }
830
+ /* Frontmatter `deprecated: true` (Mintlify's) - same amber as the
831
+ sidebar's own deprecated tag (NavTree.astro) and Parameter's
832
+ deprecated badge. */
833
+ .wd-article-title {
834
+ display: flex;
835
+ align-items: center;
836
+ flex-wrap: wrap;
837
+ gap: 0.6rem;
838
+ }
839
+ .wd-deprecated-badge {
840
+ display: inline-block;
841
+ padding: 0.15rem 0.55rem;
842
+ border-radius: 999px;
843
+ font-size: 0.8rem;
844
+ font-weight: 700;
845
+ color: #d97706;
846
+ background: color-mix(in srgb, #d97706 15%, transparent);
847
+ }
780
848
  .wd-prevnext {
781
849
  display: flex;
782
850
  justify-content: space-between;
@@ -991,6 +1059,19 @@ const components = {
991
1059
  background: var(--wd-surface);
992
1060
  border-bottom: 1px solid var(--wd-border);
993
1061
  }
1062
+ /* A fence's `icon="..."` (shiki-code-block.js) - the icon's own SVG
1063
+ arrives as a data URL in --wd-code-icon and is used as a mask, so it
1064
+ paints in the title's text color instead of the SVG's own fill. */
1065
+ .wd-code-title-icon {
1066
+ display: inline-block;
1067
+ width: 1em;
1068
+ height: 1em;
1069
+ margin-right: 0.45rem;
1070
+ vertical-align: -0.125em;
1071
+ background: currentColor;
1072
+ -webkit-mask: var(--wd-code-icon) center / contain no-repeat;
1073
+ mask: var(--wd-code-icon) center / contain no-repeat;
1074
+ }
994
1075
  .wd-code-copy-btn {
995
1076
  position: absolute;
996
1077
  top: 0.6rem;
@@ -52,6 +52,9 @@ export async function getStaticPaths() {
52
52
  // as an LLM-facing "view this page as text" export, so they're left
53
53
  // out of this route entirely rather than serving something misleading.
54
54
  .filter((entry) => !entry.data.openapi)
55
+ // A frontmatter `url` page's HTML route is a redirect to that link -
56
+ // there's no page content to serve as Markdown.
57
+ .filter((entry) => !entry.data.url)
55
58
  .map((entry) => ({
56
59
  params: { slug: normalizeEntryId(entry.id) },
57
60
  props: { entry },
@@ -49,6 +49,8 @@ export const GET: APIRoute = async () => {
49
49
  // Same noindex exclusion llms.txt.ts applies to its own listing -
50
50
  // see that file's comment.
51
51
  .filter((entry) => !entry.data.seo?.noindex)
52
+ // Same `url` exclusion as llms.txt.ts - a redirect, not a page.
53
+ .filter((entry) => !entry.data.url)
52
54
  .map((entry: DocsEntry) => {
53
55
  const slug = normalizeEntryId(entry.id);
54
56
  const body = entry.body ?? '';
@@ -85,6 +85,9 @@ export const GET: APIRoute = async () => {
85
85
  // same reason: llms.txt exists to help external tools discover
86
86
  // pages, exactly what `noindex` asked not to happen.
87
87
  .filter((entry) => !entry.data.seo?.noindex)
88
+ // A frontmatter `url` page (Mintlify's external link) has no content -
89
+ // its own URL only redirects to the link.
90
+ .filter((entry) => !entry.data.url)
88
91
  .map((entry: DocsEntry) => {
89
92
  const slug = normalizeEntryId(entry.id);
90
93
  // Same URL Astro's own router resolves this entry to - see
@@ -16,3 +16,13 @@
16
16
  classes available with zero effect on existing unstyled elements. */
17
17
  @import "tailwindcss/theme.css";
18
18
  @import "tailwindcss/utilities.css";
19
+
20
+ /* Tailwind's `dark:` variant follows the site's own theme toggle
21
+ ([data-theme="dark"] on <html>, set by BaseLayout.astro), not the OS
22
+ setting it defaults to - otherwise Mintlify-style light/dark image
23
+ pairs (className="block dark:hidden" / "hidden dark:block") show the
24
+ wrong one whenever the reader's toggle and their OS disagree. The
25
+ site's content directory is added as a Tailwind source at build time
26
+ by astro.config.mjs's contentTailwindSource() plugin, since it lives
27
+ outside this package where Tailwind looks by default. */
28
+ @custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));