@writedocs/generator 0.4.9 → 0.4.10

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.
Files changed (63) hide show
  1. package/astro.config.mjs +39 -2
  2. package/bin/writedocs.js +23 -0
  3. package/package.json +1 -1
  4. package/src/cli/convert.js +82 -0
  5. package/src/cli/generate-api-pages.js +56 -3
  6. package/src/components/Accordion.astro +2 -1
  7. package/src/components/AccordionGroup.astro +4 -1
  8. package/src/components/ApiPlayground.astro +6 -2
  9. package/src/components/ApiReferencePanel.astro +6 -2
  10. package/src/components/Badge.astro +2 -0
  11. package/src/components/Callout.astro +2 -1
  12. package/src/components/Card.astro +2 -1
  13. package/src/components/CardGroup.astro +2 -1
  14. package/src/components/Check.astro +1 -1
  15. package/src/components/CodeBlock.astro +94 -0
  16. package/src/components/CodeGroup.astro +2 -1
  17. package/src/components/Color.astro +2 -1
  18. package/src/components/ColorItem.astro +2 -1
  19. package/src/components/ColorRow.astro +2 -1
  20. package/src/components/Column.astro +19 -0
  21. package/src/components/Columns.astro +1 -1
  22. package/src/components/Danger.astro +1 -1
  23. package/src/components/Expandable.astro +2 -1
  24. package/src/components/Frame.astro +2 -1
  25. package/src/components/GitHubRepo.astro +2 -1
  26. package/src/components/Hint.astro +2 -1
  27. package/src/components/Icon.astro +3 -2
  28. package/src/components/Image.astro +2 -1
  29. package/src/components/Info.astro +1 -1
  30. package/src/components/Note.astro +1 -1
  31. package/src/components/Panel.astro +2 -1
  32. package/src/components/Parameter.astro +2 -1
  33. package/src/components/Prompt.astro +2 -1
  34. package/src/components/RequestExample.astro +2 -1
  35. package/src/components/ResponseExample.astro +2 -1
  36. package/src/components/Searchbar.astro +2 -1
  37. package/src/components/Step.astro +2 -1
  38. package/src/components/Steps.astro +4 -1
  39. package/src/components/Tab.astro +2 -1
  40. package/src/components/Tabs.astro +2 -1
  41. package/src/components/Tile.astro +2 -1
  42. package/src/components/Tip.astro +1 -1
  43. package/src/components/TreeFile.astro +2 -1
  44. package/src/components/TreeFolder.astro +2 -1
  45. package/src/components/Update.astro +2 -1
  46. package/src/components/Video.astro +2 -1
  47. package/src/components/View.astro +2 -1
  48. package/src/components/Warning.astro +1 -1
  49. package/src/components/class-names.ts +8 -0
  50. package/src/components/index.ts +2 -0
  51. package/src/content.config.ts +23 -2
  52. package/src/lib/content-check.js +36 -6
  53. package/src/lib/mdx-auto-hydrate.js +12 -0
  54. package/src/lib/mdx-inject-builtins.js +15 -0
  55. package/src/lib/mdx-inline-react.js +202 -0
  56. package/src/lib/mdx-mintlify.js +65 -0
  57. package/src/lib/mdx-substitute-variables.js +17 -0
  58. package/src/lib/mdx-unknown-components.js +56 -3
  59. package/src/lib/mintlify-convert.js +599 -0
  60. package/src/lib/openapi-ref.js +44 -0
  61. package/src/lib/openapi-render.ts +10 -1
  62. package/src/lib/pages.js +89 -17
  63. package/src/pages/[...slug].astro +8 -2
package/astro.config.mjs CHANGED
@@ -26,8 +26,14 @@ import { remarkAutoHydrateSnippets } from './src/lib/mdx-auto-hydrate.js';
26
26
  import { remarkInjectBuiltinComponents } from './src/lib/mdx-inject-builtins.js';
27
27
  import { remarkTitleAnchorIds } from './src/lib/mdx-title-anchor-ids.js';
28
28
  import { remarkSubstituteVariables } from './src/lib/mdx-substitute-variables.js';
29
- import { remarkMintlifyTreeLists, remarkMintlifyPromptText } from './src/lib/mdx-mintlify.js';
29
+ import {
30
+ remarkMintlifyTreeLists,
31
+ remarkMintlifyPromptText,
32
+ remarkMintlifyReactHooks,
33
+ mintlifyReactHooksPlugin,
34
+ } from './src/lib/mdx-mintlify.js';
30
35
  import { remarkUnknownComponentFallback } from './src/lib/mdx-unknown-components.js';
36
+ import { remarkExtractInlineReactComponents } from './src/lib/mdx-inline-react.js';
31
37
  import { writedocsTempDir, writedocsBuildStagingDir } from './src/lib/writedocs-temp-dir.js';
32
38
  import { stylesAssetFallback } from './src/lib/styles-asset-integration.js';
33
39
  import {
@@ -47,6 +53,13 @@ const contentDir = process.env.WRITEDOCS_CONTENT_DIR || process.cwd();
47
53
  // does the actual cross-filesystem-safe copy into contentDir/dist as an
48
54
  // explicit final step once the build itself is done.
49
55
  const packageRoot = process.env.WRITEDOCS_PACKAGE_ROOT || path.dirname(fileURLToPath(import.meta.url));
56
+ // Where this package's dependencies are: the node_modules folder that
57
+ // contains an installed package (<project>/node_modules/@writedocs/
58
+ // generator -> <project>/node_modules), or packageRoot itself in a checkout,
59
+ // whose own node_modules is inside it. Used for the dev server's file-serving
60
+ // allow list below.
61
+ const nodeModulesAt = packageRoot.lastIndexOf(`${path.sep}node_modules${path.sep}`);
62
+ const dependencyRoot = nodeModulesAt === -1 ? packageRoot : packageRoot.slice(0, nodeModulesAt + `${path.sep}node_modules`.length);
50
63
 
51
64
  // Read here (rather than deferred to page-render time, where
52
65
  // loadDocsConfig() is also called from [...slug].astro) specifically so
@@ -371,6 +384,14 @@ export default defineConfig({
371
384
  // the tree when it looks for components to import.
372
385
  remarkMintlifyTreeLists,
373
386
  remarkMintlifyPromptText,
387
+ // An inline component that calls React hooks moves into a generated
388
+ // .jsx file so it runs as real React (and hydrates) - see
389
+ // lib/mdx-inline-react.js. Before remarkMintlifyReactHooks, which
390
+ // imports any hooks the page itself still calls (Mintlify
391
+ // pre-injects them), and before remarkAutoHydrateSnippets, which
392
+ // hydrates the component through its new .jsx import.
393
+ remarkExtractInlineReactComponents,
394
+ remarkMintlifyReactHooks,
374
395
  // An unknown component becomes a fragment (its children still
375
396
  // render) with a warning, instead of failing the whole build - see
376
397
  // lib/mdx-unknown-components.js. After remarkMintlifyTreeLists, so
@@ -441,10 +462,26 @@ export default defineConfig({
441
462
  // working. See src/styles/global.css for the one @import that wires
442
463
  // Tailwind's utilities in.
443
464
  vite: {
465
+ // The dev server only serves files under its workspace root by default.
466
+ // A site's own snippets live in its content directory, and inline React
467
+ // components moved out of pages (lib/mdx-inline-react.js) in writedocs'
468
+ // temp directory - both need to reach the browser to hydrate. Setting
469
+ // `allow` replaces Vite's default rather than adding to it, so the
470
+ // package's dependencies must stay reachable too: when writedocs is
471
+ // installed, they sit next to it in the node_modules folder that
472
+ // contains it (React's own hydration client included), not inside
473
+ // packageRoot - see dependencyRoot above.
474
+ server: {
475
+ fs: {
476
+ allow: [packageRoot, dependencyRoot, contentDir, writedocsTempDir(contentDir)],
477
+ },
478
+ },
444
479
  // contentTailwindSource() must come before tailwindcss(): both are
445
480
  // enforce: 'pre' transforms, which Vite runs in array order, and
446
481
  // Tailwind has to see the added @source when it compiles global.css.
447
- plugins: [contentTailwindSource(), tailwindcss()],
482
+ // mintlifyReactHooksPlugin() does for a site's .jsx/.tsx snippets what
483
+ // remarkMintlifyReactHooks does for its MDX pages.
484
+ plugins: [contentTailwindSource(), tailwindcss(), mintlifyReactHooksPlugin(contentDir)],
448
485
  resolve: {
449
486
  alias: [
450
487
  // Lets a page's MDX write `import Foo from '/snippets/foo.mdx'`
package/bin/writedocs.js CHANGED
@@ -153,6 +153,29 @@ program
153
153
  }
154
154
  });
155
155
 
156
+ program
157
+ .command('convert')
158
+ .description("Convert another docs tool's config into writedocs.json")
159
+ .argument('[dir]', 'project directory (contains docs.json)', '.')
160
+ .option('--mintlify', "convert a Mintlify project's docs.json")
161
+ .option('--docs.json', 'same as --mintlify')
162
+ .option('--force', 'overwrite an existing writedocs.json')
163
+ .option('--dry-run', 'print the converted writedocs.json instead of writing it')
164
+ .action(async (dir, options) => {
165
+ // --mintlify is the only source today; the flag is still required so
166
+ // the command reads the same once other tools are added.
167
+ if (!options.mintlify && !options.docsJson) {
168
+ console.error('[writedocs] Say what to convert from: writedocs convert --mintlify [dir]');
169
+ process.exit(1);
170
+ }
171
+ const { runConvert } = await import('../src/cli/convert.js');
172
+ await runConvert({
173
+ contentDir: path.resolve(process.cwd(), dir),
174
+ force: Boolean(options.force),
175
+ dryRun: Boolean(options.dryRun),
176
+ });
177
+ });
178
+
156
179
  program
157
180
  .command('init')
158
181
  .description('Scaffold a writedocs.json and starter docs/ folder')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@writedocs/generator",
3
- "version": "0.4.9",
3
+ "version": "0.4.10",
4
4
  "description": "Static site generator for docs — a writedocs.json + MDX folder in, a static site out.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,82 @@
1
+ // `writedocs convert --mintlify [dir]` - turns a Mintlify project's
2
+ // docs.json into writedocs.json, in the same folder, then checks the pages
3
+ // the same way `writedocs validate` does. The conversion itself is
4
+ // lib/mintlify-convert.js; this file is the CLI around it.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { loadMintlifyConfig, convertMintlifyConfig, formatNotes } from '../lib/mintlify-convert.js';
8
+ import { validateDocsConfig, formatValidationIssuesDetailed } from '../lib/config-schema.js';
9
+ import { checkContent, formatContentIssues } from '../lib/content-check.js';
10
+
11
+ export async function runConvert({ contentDir, force = false, dryRun = false }) {
12
+ const docsJsonPath = path.join(contentDir, 'docs.json');
13
+ if (!fs.existsSync(docsJsonPath)) {
14
+ const legacy = path.join(contentDir, 'mint.json');
15
+ if (fs.existsSync(legacy)) {
16
+ console.error(
17
+ `[writedocs] Found mint.json, Mintlify's older config format. Run \`npx mint upgrade\` in ${contentDir} to turn it into docs.json, then run this again.`
18
+ );
19
+ } else {
20
+ console.error(`[writedocs] No docs.json found in ${contentDir}.`);
21
+ }
22
+ process.exit(1);
23
+ }
24
+ const outPath = path.join(contentDir, 'writedocs.json');
25
+ if (!dryRun && !force && fs.existsSync(outPath)) {
26
+ console.error(`[writedocs] ${outPath} already exists. Pass --force to overwrite it, or --dry-run to only see the result.`);
27
+ process.exit(1);
28
+ }
29
+
30
+ let docs;
31
+ try {
32
+ docs = loadMintlifyConfig(docsJsonPath);
33
+ } catch (err) {
34
+ console.error(`[writedocs] Couldn't read ${docsJsonPath}: ${err.message}`);
35
+ process.exit(1);
36
+ }
37
+
38
+ const { config, notes } = convertMintlifyConfig(docs);
39
+ const text = `${JSON.stringify(config, null, 2)}\n`;
40
+
41
+ // The result must pass writedocs' own schema - if it doesn't, that's a
42
+ // converter bug, and writing it would only hand the author a broken file.
43
+ const result = validateDocsConfig(text);
44
+ if (!result.ok) {
45
+ console.error('[writedocs] The converted writedocs.json is not valid - this is a bug in the converter, please report it:\n');
46
+ console.error(formatValidationIssuesDetailed(result.issues));
47
+ console.error(`\n${text}`);
48
+ process.exit(1);
49
+ }
50
+
51
+ if (dryRun) {
52
+ console.log(text);
53
+ } else {
54
+ fs.writeFileSync(outPath, text);
55
+ console.log(`[writedocs] Converted ${path.basename(docsJsonPath)} -> ${outPath}`);
56
+ }
57
+
58
+ if (notes.length) {
59
+ console.log(`\n[writedocs] ${notes.length} thing${notes.length === 1 ? '' : 's'} couldn't be carried over as-is:\n`);
60
+ console.log(formatNotes(notes));
61
+ }
62
+
63
+ // The pages, checked against the converted config - what's left to fix
64
+ // before the first build.
65
+ const content = await checkContent(contentDir, text);
66
+ if (content.errors.length) {
67
+ console.log(`\n[writedocs] ${content.errors.length} error${content.errors.length === 1 ? '' : 's'} in the pages - the build would fail on these:\n`);
68
+ console.log(formatContentIssues(content.errors));
69
+ }
70
+ if (content.warnings.length) {
71
+ console.log(`\n[writedocs] ${content.warnings.length} warning${content.warnings.length === 1 ? '' : 's'} in the pages:\n`);
72
+ console.log(formatContentIssues(content.warnings));
73
+ }
74
+ console.log(
75
+ `\n[writedocs] Checked ${content.pages} page${content.pages === 1 ? '' : 's'}: ${content.errors.length} error${content.errors.length === 1 ? '' : 's'}, ${content.warnings.length} warning${content.warnings.length === 1 ? '' : 's'}.`
76
+ );
77
+ if (!config.domain) {
78
+ console.log(
79
+ '[writedocs] Next: set "domain" in writedocs.json to your site\'s URL - it turns on sitemap.xml and absolute links for social previews. (Mintlify sets this in its dashboard, not docs.json.)'
80
+ );
81
+ }
82
+ }
@@ -3,6 +3,8 @@ import path from 'node:path';
3
3
  import SwaggerParser from '@apidevtools/swagger-parser';
4
4
  import matter from 'gray-matter';
5
5
  import { writedocsTempDir } from '../lib/writedocs-temp-dir.js';
6
+ import { parseOpenApiRef, openApiOperationKey, specDirName } from '../lib/openapi-ref.js';
7
+ import { findAllPages } from '../lib/pages.js';
6
8
 
7
9
  const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
8
10
 
@@ -99,10 +101,17 @@ function scanDirForOverrides(dir, relBase, overrides) {
99
101
  }
100
102
  if (!/\.mdx?$/i.test(entry.name)) continue;
101
103
  const raw = fs.readFileSync(full, 'utf-8');
102
- const { data } = matter(raw);
104
+ let data;
105
+ try {
106
+ ({ data } = matter(raw));
107
+ } catch {
108
+ continue; // invalid frontmatter - `writedocs validate` and the build report it
109
+ }
103
110
  if (typeof data.openapi !== 'string') continue;
104
111
  const fileId = rel.replace(/\.mdx?$/i, '').replace(/\/index$/, '');
105
- overrides.set(data.openapi.trim().replace(/\s+/g, ' '), fileId);
112
+ // Keyed by "METHOD /path" whichever form the page wrote - Mintlify's
113
+ // "spec.json METHOD /path" included (see lib/openapi-ref.js).
114
+ overrides.set(openApiOperationKey(data.openapi) ?? data.openapi.trim().replace(/\s+/g, ' '), fileId);
106
115
  }
107
116
  }
108
117
 
@@ -337,7 +346,6 @@ export async function generateApiPages({ contentDir }) {
337
346
  rmrf(openapiOutDir);
338
347
 
339
348
  const groups = collectOpenApiGroups(config.navigation);
340
- if (groups.length === 0) return;
341
349
 
342
350
  const seenPaths = new Map(); // normalized path -> owning group's label
343
351
  for (const group of groups) {
@@ -356,4 +364,49 @@ export async function generateApiPages({ contentDir }) {
356
364
  for (const group of groups) {
357
365
  await generateApiPagesForGroup({ contentDir, generatedDocsDir, group, overrides });
358
366
  }
367
+
368
+ await parsePageSpecs({ contentDir, openapiOutDir, groupSpecs: groups.map((g) => path.resolve(contentDir, g.openapi.src)) });
369
+ }
370
+
371
+ /** Mintlify's page-level form, `openapi: "/spec.json METHOD /path"`: the
372
+ * page names its spec itself instead of belonging to an openapi group in
373
+ * writedocs.json. Every such spec (that no group already parsed) is parsed
374
+ * here and its operations written under openapi/_pages/<spec>/operations/,
375
+ * where findOperationFile() (lib/openapi-render.ts) looks for them. No
376
+ * pages are generated - the pages that name the spec are the pages. A
377
+ * spec that's missing or doesn't parse is a warning, not a build failure:
378
+ * its pages show the playground's own "no operation found" notice. */
379
+ async function parsePageSpecs({ contentDir, openapiOutDir, groupSpecs }) {
380
+ const specs = new Set();
381
+ for (const rel of findAllPages(contentDir)) {
382
+ let data;
383
+ try {
384
+ ({ data } = matter(fs.readFileSync(path.join(contentDir, rel), 'utf-8')));
385
+ } catch {
386
+ continue;
387
+ }
388
+ const ref = parseOpenApiRef(data?.openapi);
389
+ if (ref?.spec) specs.add(ref.spec);
390
+ }
391
+ for (const spec of specs) {
392
+ const specPath = path.resolve(contentDir, spec);
393
+ if (groupSpecs.includes(specPath)) continue;
394
+ if (!fs.existsSync(specPath)) {
395
+ console.warn(`[writedocs] Pages name the OpenAPI spec "${spec}", which doesn't exist - their API playground shows a notice instead.`);
396
+ continue;
397
+ }
398
+ let operations;
399
+ try {
400
+ operations = buildOperations(await SwaggerParser.dereference(specPath));
401
+ } catch (err) {
402
+ console.warn(`[writedocs] Couldn't parse the OpenAPI spec "${spec}" that pages name: ${err.message}`);
403
+ continue;
404
+ }
405
+ const outDir = path.join(openapiOutDir, '_pages', specDirName(spec), 'operations');
406
+ fs.mkdirSync(outDir, { recursive: true });
407
+ for (const operation of operations) {
408
+ fs.writeFileSync(path.join(outDir, operationFileName(operation.method, operation.path)), JSON.stringify(operation, null, 2));
409
+ }
410
+ console.log(`[writedocs] Parsed OpenAPI spec "${spec}" for the pages that name it (${operations.length} operations)`);
411
+ }
359
412
  }
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { extraClasses } from './class-names';
2
3
  import AppIcon from "./AppIcon.astro";
3
4
 
4
5
  interface Props {
@@ -32,7 +33,7 @@ function slugify(value: string): string {
32
33
  const titleId = _titleId ?? slugify(title);
33
34
  ---
34
35
 
35
- <details class="wd-accordion" open={defaultOpen}>
36
+ <details class:list={["wd-accordion", extraClasses(Astro.props)]} open={defaultOpen}>
36
37
  <summary>
37
38
  <span class="wd-accordion-heading">
38
39
  {icon && <AppIcon icon={icon} class="wd-accordion-icon" />}
@@ -1,4 +1,7 @@
1
- <div class="wd-accordion-group">
1
+ ---
2
+ import { extraClasses } from './class-names';
3
+ ---
4
+ <div class:list={["wd-accordion-group", extraClasses(Astro.props)]}>
2
5
  <slot />
3
6
  </div>
4
7
  <style is:global>
@@ -12,6 +12,7 @@ import {
12
12
  findOperationFile,
13
13
  type OpenApiOperation,
14
14
  } from '../lib/openapi-render';
15
+ import { parseOpenApiRef } from '../lib/openapi-ref.js';
15
16
 
16
17
  interface Props {
17
18
  operation: string; // "METHOD /path", matching a page's `openapi` frontmatter
@@ -19,8 +20,11 @@ interface Props {
19
20
  }
20
21
  const { operation, contentDir } = Astro.props as Props;
21
22
 
22
- const [method = '', urlPath = ''] = operation.trim().split(/\s+/, 2);
23
- const opFile = findOperationFile(contentDir, method, urlPath);
23
+ // "METHOD /path", or Mintlify's "spec.json METHOD /path" - see lib/openapi-ref.js.
24
+ const ref = parseOpenApiRef(operation);
25
+ const method = ref?.method ?? '';
26
+ const urlPath = ref?.path ?? '';
27
+ const opFile = ref ? findOperationFile(contentDir, method, urlPath, ref.spec) : null;
24
28
  const op: OpenApiOperation | null = opFile ? JSON.parse(fs.readFileSync(opFile, 'utf-8')) : null;
25
29
 
26
30
  const pathParams = op?.parameters.filter((p) => p.in === 'path') ?? [];
@@ -15,6 +15,7 @@ import {
15
15
  findOperationFile,
16
16
  type OpenApiOperation,
17
17
  } from '../lib/openapi-render';
18
+ import { parseOpenApiRef } from '../lib/openapi-ref.js';
18
19
  import { loadDocsConfig, resolveCodeblockTheme } from '../lib/config';
19
20
  import ApiLangSelect from './ApiLangSelect.astro';
20
21
 
@@ -64,8 +65,11 @@ const SNIPPET_THEMES = resolveCodeblockTheme(docsConfig);
64
65
  // writedocs' CORS proxy - see PROXY_BASE_URL in the <script> below.
65
66
  const proxyEnabled = docsConfig.api.proxy;
66
67
 
67
- const [method = '', urlPath = ''] = operation.trim().split(/\s+/, 2);
68
- const opFile = findOperationFile(contentDir, method, urlPath);
68
+ // "METHOD /path", or Mintlify's "spec.json METHOD /path" - see lib/openapi-ref.js.
69
+ const ref = parseOpenApiRef(operation);
70
+ const method = ref?.method ?? '';
71
+ const urlPath = ref?.path ?? '';
72
+ const opFile = ref ? findOperationFile(contentDir, method, urlPath, ref.spec) : null;
69
73
  const op: OpenApiOperation | null = opFile ? JSON.parse(fs.readFileSync(opFile, 'utf-8')) : null;
70
74
 
71
75
  // ApiPlayground.astro (rendered in the article column) already shows a
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { extraClasses } from './class-names';
2
3
  // A small inline label - status indicators, version tags, "Beta"/"New"
3
4
  // markers - modeled on Mintlify's own Badge
4
5
  // (https://www.mintlify.com/docs/components/badge). Renders as an
@@ -42,6 +43,7 @@ const shapeClass = shape === 'pill' ? 'pill' : 'rounded';
42
43
  `wd-badge-${shapeClass}`,
43
44
  stroke && 'wd-badge-stroke',
44
45
  disabled && 'wd-badge-disabled',
46
+ extraClasses(Astro.props),
45
47
  ]}
46
48
  >
47
49
  {icon && <AppIcon icon={icon} class="wd-badge-icon" />}<slot />
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { extraClasses } from './class-names';
2
3
  // See src/components/{Note,Tip,Warning,Danger,Info,Check}.astro - one thin
3
4
  // wrapper per `type` below, each just `<Callout type="x">` under the
4
5
  // hood, so a page can write either `<Callout type="info">` or the
@@ -51,7 +52,7 @@ function slugify(value: string): string {
51
52
  const titleId = title ? (_titleId ?? slugify(title)) : undefined;
52
53
  ---
53
54
 
54
- <div class:list={["wd-callout", `wd-callout-${type}`, { "wd-callout-titled": Boolean(title) }]} style={accentStyle}>
55
+ <div class:list={["wd-callout", `wd-callout-${type}`, { "wd-callout-titled": Boolean(title) }, extraClasses(Astro.props)]} style={accentStyle}>
55
56
  <AppIcon icon={icon ?? icons[type]} class="wd-callout-icon" />
56
57
  <div class="wd-callout-content">
57
58
  {
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { extraClasses } from './class-names';
2
3
  import AppIcon from './AppIcon.astro';
3
4
 
4
5
  // Three visual variants, chosen by which optional prop is set - never more
@@ -42,7 +43,7 @@ const KNOWN_METHODS = ['get', 'post', 'put', 'patch', 'delete'];
42
43
  const methodLower = method?.toLowerCase();
43
44
  const methodClass = methodLower && KNOWN_METHODS.includes(methodLower) ? methodLower : 'other';
44
45
  ---
45
- <Tag class:list={['wd-card', { 'wd-card-horizontal': horizontal, 'wd-card-has-arrow': arrow && href }]} href={href}>
46
+ <Tag class:list={['wd-card', { 'wd-card-horizontal': horizontal, 'wd-card-has-arrow': arrow && href }, extraClasses(Astro.props)]} href={href}>
46
47
  {img && <img src={img} alt="" class="wd-card-image" />}
47
48
  {!img && <AppIcon icon={icon} class="wd-card-icon" style={iconStyle} />}
48
49
  <div class="wd-card-text">
@@ -1,10 +1,11 @@
1
1
  ---
2
+ import { extraClasses } from './class-names';
2
3
  interface Props {
3
4
  cols?: number;
4
5
  }
5
6
  const { cols = 2 } = Astro.props as Props;
6
7
  ---
7
- <div class="wd-card-group" style={`--wd-cols: ${cols}`}>
8
+ <div class:list={["wd-card-group", extraClasses(Astro.props)]} style={`--wd-cols: ${cols}`}>
8
9
  <slot />
9
10
  </div>
10
11
  <style>
@@ -10,4 +10,4 @@ interface Props {
10
10
  }
11
11
  const { title, _titleId } = Astro.props as Props;
12
12
  ---
13
- <Callout type="check" title={title} _titleId={_titleId}><slot /></Callout>
13
+ <Callout type="check" title={title} _titleId={_titleId} class={Astro.props.class} className={Astro.props.className}><slot /></Callout>
@@ -0,0 +1,94 @@
1
+ ---
2
+ // Mintlify's <CodeBlock> - a code block from props instead of a ``` fence,
3
+ // for code a page builds up in a component. Renders through the same Shiki
4
+ // setup and the same writedocs:code-block transformer as a fenced block
5
+ // (see astro.config.mjs's markdown.shikiConfig), so title, icon, line
6
+ // numbers, wrap, copy button, expandable, highlight and focus all look and
7
+ // behave the same.
8
+ //
9
+ // The code is the component's children (or a `code` prop). Mintlify's
10
+ // `highlight`/`focus` are stringified arrays ("[1,3,4]"); ranges like
11
+ // "1-3" work too.
12
+ import { Code } from 'astro:components';
13
+ import { extraClasses } from './class-names';
14
+ import {
15
+ transformerMetaHighlight,
16
+ transformerMetaWordHighlight,
17
+ transformerNotationHighlight,
18
+ transformerNotationWordHighlight,
19
+ transformerNotationFocus,
20
+ transformerNotationDiff,
21
+ transformerNotationErrorLevel,
22
+ } from '@shikijs/transformers';
23
+ import { codeBlockTransformer } from '../lib/shiki-code-block.js';
24
+ import { loadDocsConfig, resolveCodeblockTheme } from '../lib/config';
25
+
26
+ interface Props {
27
+ language?: string;
28
+ filename?: string;
29
+ icon?: string;
30
+ lines?: boolean;
31
+ wrap?: boolean;
32
+ nocopy?: boolean;
33
+ expandable?: boolean;
34
+ highlight?: string | number[];
35
+ focus?: string | number[];
36
+ code?: string;
37
+ }
38
+ const { language = 'text', filename, icon, lines, wrap, nocopy, expandable, highlight, focus, code: codeProp } = Astro.props as Props;
39
+
40
+ // Slot content arrives as rendered HTML text - undo its entity escaping.
41
+ function unescape(html: string): string {
42
+ return html
43
+ .replace(/<[^>]+>/g, '')
44
+ .replace(/&lt;/g, '<')
45
+ .replace(/&gt;/g, '>')
46
+ .replace(/&quot;/g, '"')
47
+ .replace(/&#39;/g, "'")
48
+ .replace(/&#x27;/g, "'")
49
+ .replace(/&amp;/g, '&');
50
+ }
51
+ const code = (codeProp ?? unescape(await Astro.slots.render('default'))).replace(/^\n+|\s+$/g, '');
52
+
53
+ const ranges = (value: string | number[] | undefined) =>
54
+ value === undefined ? '' : (Array.isArray(value) ? value.join(',') : String(value).replace(/[\[\]\s]/g, ''));
55
+ const quoted = (value: string) => `"${value.replace(/"/g, "'")}"`;
56
+ const meta = [
57
+ filename ? `title=${quoted(filename)}` : '',
58
+ icon ? `icon=${quoted(icon)}` : '',
59
+ lines ? 'lines' : '',
60
+ wrap ? 'wrap' : '',
61
+ nocopy ? 'nocopy' : '',
62
+ expandable ? 'expandable' : '',
63
+ ranges(highlight) ? `highlight={${ranges(highlight)}}` : '',
64
+ ranges(focus) ? `focus={${ranges(focus)}}` : '',
65
+ ]
66
+ .filter(Boolean)
67
+ .join(' ');
68
+
69
+ const contentDir = process.env.WRITEDOCS_CONTENT_DIR || process.cwd();
70
+ const themes = resolveCodeblockTheme(loadDocsConfig(contentDir));
71
+ const transformers = [
72
+ transformerMetaHighlight(),
73
+ transformerMetaWordHighlight(),
74
+ transformerNotationHighlight(),
75
+ transformerNotationWordHighlight(),
76
+ transformerNotationFocus(),
77
+ transformerNotationDiff(),
78
+ transformerNotationErrorLevel(),
79
+ codeBlockTransformer(),
80
+ ];
81
+ const extras = extraClasses(Astro.props);
82
+ ---
83
+ {
84
+ // The block's own root (.wd-code-block) is built by the Shiki transformer,
85
+ // so `className`/`class` goes on a wrapper around it - only when given, so
86
+ // a plain CodeBlock renders exactly like a fenced block.
87
+ extras.length > 0 ? (
88
+ <div class:list={extras}>
89
+ <Code code={code} lang={language as any} meta={meta} themes={themes} transformers={transformers} />
90
+ </div>
91
+ ) : (
92
+ <Code code={code} lang={language as any} meta={meta} themes={themes} transformers={transformers} />
93
+ )
94
+ }
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { extraClasses } from './class-names';
2
3
  // `dropdown` is only ever passed down from RequestExample/
3
4
  // ResponseExample.astro today (CodeGroup itself is never used with it
4
5
  // directly in any fixture/doc) - kept as a real prop rather than a
@@ -9,7 +10,7 @@ interface Props {
9
10
  }
10
11
  const { dropdown = false } = Astro.props as Props;
11
12
  ---
12
- <div class="wd-codegroup" data-dropdown={dropdown ? "true" : undefined}>
13
+ <div class:list={["wd-codegroup", extraClasses(Astro.props)]} data-dropdown={dropdown ? "true" : undefined}>
13
14
  <slot />
14
15
  </div>
15
16
  <script>
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { extraClasses } from './class-names';
2
3
  // Mintlify's <Color> - a palette of <Color.Item> swatches. `variant`:
3
4
  // compact (default) - one grid of swatches.
4
5
  // table - <Color.Row title="..."> rows, each a titled line of
@@ -10,7 +11,7 @@ interface Props {
10
11
  }
11
12
  const { variant = 'compact' } = Astro.props as Props;
12
13
  ---
13
- <div class:list={['wd-color', `wd-color-${variant}`]}><slot /></div>
14
+ <div class:list={['wd-color', `wd-color-${variant}`, extraClasses(Astro.props)]}><slot /></div>
14
15
  <script>
15
16
  // Click (or Enter/Space) on a swatch copies the value currently shown -
16
17
  // for a light/dark pair, the one matching the site's theme right now.
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { extraClasses } from './class-names';
2
3
  // Mintlify's <Color.Item name="..." value="..."> - one swatch. `value` is
3
4
  // any CSS color, or { light, dark } for a theme-aware pair: the swatch and
4
5
  // the value text switch with the site's theme toggle ([data-theme] on
@@ -15,7 +16,7 @@ const themed = typeof value === 'object' && value !== null && light !== dark;
15
16
  const style = light ? `--wd-swatch-light: ${light}; --wd-swatch-dark: ${dark ?? light}` : undefined;
16
17
  ---
17
18
  <div
18
- class="wd-color-item"
19
+ class:list={['wd-color-item', extraClasses(Astro.props)]}
19
20
  data-light={light}
20
21
  data-dark={dark}
21
22
  role={light ? 'button' : undefined}
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { extraClasses } from './class-names';
2
3
  // Mintlify's <Color.Row title="..."> - one titled row inside
3
4
  // <Color variant="table">. `title` takes inline Markdown.
4
5
  import { inlineMarkdown } from '../lib/inline-markdown.js';
@@ -7,7 +8,7 @@ interface Props {
7
8
  }
8
9
  const { title } = Astro.props as Props;
9
10
  ---
10
- <div class="wd-color-row">
11
+ <div class:list={['wd-color-row', extraClasses(Astro.props)]}>
11
12
  <div class="wd-color-row-title" set:html={inlineMarkdown(title ?? '')} />
12
13
  <div class="wd-color-row-items"><slot /></div>
13
14
  </div>
@@ -0,0 +1,19 @@
1
+ ---
2
+ import { extraClasses } from './class-names';
3
+ // Mintlify's <Column> - one cell of a <Columns> grid, for arbitrary content
4
+ // (text, a code block) side by side rather than Cards. Just a grid item:
5
+ // <Columns>/CardGroup.astro lays its children out, this only keeps the
6
+ // cell's content together and trims the outer margins of what's inside.
7
+ ---
8
+ <div class:list={['wd-column', extraClasses(Astro.props)]}><slot /></div>
9
+ <style>
10
+ .wd-column {
11
+ min-width: 0;
12
+ }
13
+ .wd-column > :global(:first-child) {
14
+ margin-top: 0;
15
+ }
16
+ .wd-column > :global(:last-child) {
17
+ margin-bottom: 0;
18
+ }
19
+ </style>
@@ -8,4 +8,4 @@ interface Props {
8
8
  }
9
9
  const { cols } = Astro.props as Props;
10
10
  ---
11
- <CardGroup cols={cols}><slot /></CardGroup>
11
+ <CardGroup cols={cols} class={Astro.props.class} className={Astro.props.className}><slot /></CardGroup>
@@ -9,4 +9,4 @@ interface Props {
9
9
  }
10
10
  const { title, _titleId } = Astro.props as Props;
11
11
  ---
12
- <Callout type="danger" title={title} _titleId={_titleId}><slot /></Callout>
12
+ <Callout type="danger" title={title} _titleId={_titleId} class={Astro.props.class} className={Astro.props.className}><slot /></Callout>
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { extraClasses } from './class-names';
2
3
  // The collapsible "Show/Hide properties" wrapper that pairs with
3
4
  // Parameter for nested object properties - modeled directly on
4
5
  // Mintlify's own Expandable (https://www.mintlify.com/docs/components/
@@ -25,7 +26,7 @@ interface Props {
25
26
  const { title = "properties", defaultOpen = true } = Astro.props as Props;
26
27
  ---
27
28
 
28
- <details class="wd-expandable" open={defaultOpen}>
29
+ <details class:list={["wd-expandable", extraClasses(Astro.props)]} open={defaultOpen}>
29
30
  <summary>
30
31
  <svg class="wd-expandable-chevron" width="11" height="11" viewBox="0 0 10 10" aria-hidden="true">
31
32
  <path
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { extraClasses } from './class-names';
2
3
  // A generic "put a rounded, bordered card around this" wrapper -
3
4
  // deliberately different from Image (a specific <img> with its own
4
5
  // src/srcDark/size handling): Frame wraps *arbitrary* slot content - a
@@ -21,7 +22,7 @@ interface Props {
21
22
  const { caption, hint } = Astro.props as Props;
22
23
  ---
23
24
 
24
- <figure class="wd-frame">
25
+ <figure class:list={["wd-frame", extraClasses(Astro.props)]}>
25
26
  {hint && <p class="wd-frame-hint">{hint}</p>}
26
27
  <div class="wd-frame-content">
27
28
  <slot />