@writedocs/generator 0.8.0 → 0.8.1
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/astro.config.mjs +4 -1
- package/bin/writedocs.js +2 -0
- package/package.json +1 -1
- package/src/cli/convert.js +3 -0
- package/src/cli/dev.js +9 -1
- package/src/cli/open-browser.js +32 -0
- package/src/components/CopyPageMenu.astro +60 -24
- package/src/lib/config-schema.js +24 -5
- package/src/lib/config-schema.ts +43 -18
- package/src/lib/content-check.js +15 -0
- package/src/lib/json-schema-descriptions.js +2 -2
- package/src/lib/link-check.js +2 -2
- package/src/lib/llms-index.ts +3 -3
- package/src/lib/mintlify-convert.js +7 -4
- package/src/pages/[...slug].astro +21 -5
- package/src/pages/[...slug].md.ts +8 -8
- package/writedocs.schema.json +29 -14
package/astro.config.mjs
CHANGED
|
@@ -107,7 +107,10 @@ const contentPublicDir = path.join(contentDir, 'public');
|
|
|
107
107
|
// document-relative URLs isn't meaningful anyway.
|
|
108
108
|
const siteUrl = resolveSiteUrl(docsConfig);
|
|
109
109
|
if (!siteUrl) {
|
|
110
|
-
report(
|
|
110
|
+
report(
|
|
111
|
+
'info',
|
|
112
|
+
'No "domain" set in writedocs.json, so no sitemap.xml was generated, and llms.txt, the MCP server\'s page URLs and the "Open in…" links in the page menu use relative addresses or the address the page is opened from. Set "domain" to the site\'s address, like "docs.example.com".'
|
|
113
|
+
);
|
|
111
114
|
}
|
|
112
115
|
|
|
113
116
|
/** Recursively lists every .md/.mdx file under `baseDir` (Node 20's
|
package/bin/writedocs.js
CHANGED
|
@@ -61,12 +61,14 @@ program
|
|
|
61
61
|
.argument('[dir]', 'content directory (contains writedocs.json and docs/)', '.')
|
|
62
62
|
.option('-p, --port <port>', 'port to run on')
|
|
63
63
|
.option('--verbose', "also print Astro's and Vite's own output")
|
|
64
|
+
.option('--no-open', "don't open the preview in the browser")
|
|
64
65
|
.action(async (dir, opts) => {
|
|
65
66
|
await runDev({
|
|
66
67
|
contentDir: path.resolve(process.cwd(), dir),
|
|
67
68
|
packageRoot,
|
|
68
69
|
port: opts.port,
|
|
69
70
|
verbose: Boolean(opts.verbose),
|
|
71
|
+
open: opts.open,
|
|
70
72
|
});
|
|
71
73
|
});
|
|
72
74
|
|
package/package.json
CHANGED
package/src/cli/convert.js
CHANGED
|
@@ -96,6 +96,9 @@ export async function runConvert({ source = 'mintlify', contentDir, force = fals
|
|
|
96
96
|
// before the first build.
|
|
97
97
|
const checking = step('Checking pages');
|
|
98
98
|
const content = await checkContent(contentDir, text);
|
|
99
|
+
// The domain reminder comes once, at the end, with where the source tool
|
|
100
|
+
// kept that setting.
|
|
101
|
+
content.warnings = content.warnings.filter((w) => w.code !== 'no-domain');
|
|
99
102
|
checking.stop();
|
|
100
103
|
if (content.errors.length) {
|
|
101
104
|
log.line();
|
package/src/cli/dev.js
CHANGED
|
@@ -6,13 +6,14 @@ import { describeError, requestLog, authorWarning, verboseLine, stripAnsi } from
|
|
|
6
6
|
import { reportApiPages } from './api-pages-output.js';
|
|
7
7
|
import { runningPreview, writeLock, removeLock } from './dev-lock.js';
|
|
8
8
|
import { showUpdateNotice } from './update-check.js';
|
|
9
|
+
import { shouldOpenBrowser, openBrowser } from './open-browser.js';
|
|
9
10
|
|
|
10
11
|
// The same problem tends to arrive more than once in a row - Vite and
|
|
11
12
|
// Astro each log a failed page, and a page compiles for more than one
|
|
12
13
|
// environment. Within this window, a repeat is dropped.
|
|
13
14
|
const REPEAT_WINDOW_MS = 2000;
|
|
14
15
|
|
|
15
|
-
export async function runDev({ contentDir, packageRoot, port, verbose = false }) {
|
|
16
|
+
export async function runDev({ contentDir, packageRoot, port, verbose = false, open = true }) {
|
|
16
17
|
const started = Date.now();
|
|
17
18
|
preflightCheck(contentDir);
|
|
18
19
|
writableInstallCheck(packageRoot);
|
|
@@ -36,6 +37,7 @@ export async function runDev({ contentDir, packageRoot, port, verbose = false })
|
|
|
36
37
|
|
|
37
38
|
const starting = step('Starting local preview');
|
|
38
39
|
let ready = false;
|
|
40
|
+
let browserOpened = false;
|
|
39
41
|
|
|
40
42
|
const lastShown = new Map();
|
|
41
43
|
const once = (key, print) => {
|
|
@@ -102,6 +104,12 @@ export async function runDev({ contentDir, packageRoot, port, verbose = false })
|
|
|
102
104
|
// `dev` runs until Ctrl+C - the notice goes under the ready screen,
|
|
103
105
|
// not after the command like everywhere else.
|
|
104
106
|
showUpdateNotice();
|
|
107
|
+
// The preview in the browser, at the address it actually got - once,
|
|
108
|
+
// not again if Astro restarts the server.
|
|
109
|
+
if (!browserOpened && shouldOpenBrowser({ open })) {
|
|
110
|
+
browserOpened = true;
|
|
111
|
+
openBrowser(local ?? `http://localhost:${wanted}/`);
|
|
112
|
+
}
|
|
105
113
|
return;
|
|
106
114
|
}
|
|
107
115
|
case 'fatal':
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// `writedocs dev` opens the preview in the default browser once it's ready.
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
|
|
4
|
+
/** Whether to open the browser: not with `--no-open`, not with BROWSER=none
|
|
5
|
+
* (the convention Vite and create-react-app follow), and not where nobody's
|
|
6
|
+
* watching - CI, or output that isn't an interactive terminal. */
|
|
7
|
+
export function shouldOpenBrowser({ open = true, env = process.env, isTTY = Boolean(process.stdout.isTTY) } = {}) {
|
|
8
|
+
if (!open || !isTTY || env.CI) return false;
|
|
9
|
+
return String(env.BROWSER ?? '').toLowerCase() !== 'none';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The command that opens `url` in the default browser on `platform`. On
|
|
13
|
+
* Windows, `start`'s first quoted argument is a window title - hence the
|
|
14
|
+
* empty one, which Node passes as "". */
|
|
15
|
+
export function openCommand(url, platform = process.platform) {
|
|
16
|
+
if (platform === 'win32') return ['cmd', ['/c', 'start', '', url]];
|
|
17
|
+
if (platform === 'darwin') return ['open', [url]];
|
|
18
|
+
return ['xdg-open', [url]];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Opens `url`, best effort: a machine with no browser (or no xdg-open)
|
|
22
|
+
* just doesn't get one - the URL is printed anyway. */
|
|
23
|
+
export function openBrowser(url, { platform = process.platform, run = spawn } = {}) {
|
|
24
|
+
const [command, args] = openCommand(url, platform);
|
|
25
|
+
try {
|
|
26
|
+
const child = run(command, args, { stdio: 'ignore', detached: true, windowsHide: true });
|
|
27
|
+
child.on?.('error', () => {});
|
|
28
|
+
child.unref?.();
|
|
29
|
+
} catch {
|
|
30
|
+
// no browser to open - fine
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
|
-
// The "Copy page" split button -
|
|
3
|
-
//
|
|
2
|
+
// The "Copy page" split button - on by default, showing the options
|
|
3
|
+
// writedocs.json's `contextMenu` leaves on (`options`, from
|
|
4
|
+
// contextMenuOptions() in lib/config-schema.ts). With `copy`, a primary button
|
|
4
5
|
// (always-visible "Copy page" action, copies this page's Markdown
|
|
5
6
|
// straight to the clipboard on click - see initCopyPageMenu() in
|
|
6
7
|
// [...slug].astro) plus a small caret-only button next to it that opens
|
|
@@ -19,19 +20,21 @@
|
|
|
19
20
|
// Rendered from [...slug].astro (see its own comment on where/when),
|
|
20
21
|
// which passes in everything needed to build both same-origin links (the
|
|
21
22
|
// .md route from [...slug].md.ts) and the external "Open in X" links
|
|
22
|
-
// (which need an absolute URL -
|
|
23
|
-
//
|
|
24
|
-
//
|
|
23
|
+
// (which need an absolute URL - from writedocs.json's `domain` when it's
|
|
24
|
+
// set, else from the page's own address in the browser; see
|
|
25
|
+
// enabledAssistants below).
|
|
26
|
+
// Without `copy`, the dropdown gets a labeled trigger of its own; with only
|
|
27
|
+
// `copy`, the primary button stands alone.
|
|
25
28
|
import { Icon } from 'astro-icon/components';
|
|
26
|
-
import type {
|
|
29
|
+
import type { ContextMenuOption } from '../lib/config';
|
|
27
30
|
|
|
28
31
|
interface Props {
|
|
29
32
|
currentPath: string; // e.g. "/" or "/guides/foo/" - see hrefForSlug() in [...slug].astro
|
|
30
33
|
siteUrl: string | null;
|
|
31
|
-
|
|
34
|
+
options: ContextMenuOption[];
|
|
32
35
|
}
|
|
33
36
|
|
|
34
|
-
const { currentPath, siteUrl,
|
|
37
|
+
const { currentPath, siteUrl, options } = Astro.props as Props;
|
|
35
38
|
|
|
36
39
|
// The [...slug].md.ts route this page is also served at - same
|
|
37
40
|
// normalizeEntryId()-driven convention that file uses to build its own
|
|
@@ -50,33 +53,49 @@ const mdAbsoluteUrl = siteUrl ? `${siteUrl}${mdPath}` : null;
|
|
|
50
53
|
// a bookmarked/shared link.
|
|
51
54
|
const promptFor = (url: string) => `Read ${url} so you can answer questions about it.`;
|
|
52
55
|
|
|
56
|
+
// `base` + the URL-encoded prompt is the link; `home` is where it goes
|
|
57
|
+
// before the page script has filled the prompt in (see below).
|
|
53
58
|
const assistants = [
|
|
54
|
-
{ id: 'chatgpt' as const, label: 'ChatGPT', icon: 'simple-icons:openai',
|
|
55
|
-
{ id: 'claude' as const, label: 'Claude', icon: 'simple-icons:claude',
|
|
56
|
-
{ id: 'perplexity' as const, label: 'Perplexity', icon: 'simple-icons:perplexity',
|
|
59
|
+
{ id: 'chatgpt' as const, label: 'ChatGPT', icon: 'simple-icons:openai', base: 'https://chatgpt.com/?q=', home: 'https://chatgpt.com/' },
|
|
60
|
+
{ id: 'claude' as const, label: 'Claude', icon: 'simple-icons:claude', base: 'https://claude.ai/new?q=', home: 'https://claude.ai/new' },
|
|
61
|
+
{ id: 'perplexity' as const, label: 'Perplexity', icon: 'simple-icons:perplexity', base: 'https://www.perplexity.ai/search?q=', home: 'https://www.perplexity.ai/' },
|
|
57
62
|
];
|
|
58
63
|
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
// The assistant needs the page's absolute URL to fetch it. With a `domain`
|
|
65
|
+
// it's known at build time and the link is complete in the HTML. Without
|
|
66
|
+
// one, the page script (initCopyPageMenu() in [...slug].astro) completes it
|
|
67
|
+
// from the address the page is actually served from - `data-ask-base` +
|
|
68
|
+
// the prompt for that URL, the same prompt promptFor() writes.
|
|
69
|
+
const enabledAssistants = assistants.filter((a) => options.includes(a.id));
|
|
70
|
+
const assistantHref = (a: (typeof assistants)[number]) => (mdAbsoluteUrl ? a.base + encodeURIComponent(promptFor(mdAbsoluteUrl)) : a.home);
|
|
71
|
+
const showCopy = options.includes('copy');
|
|
72
|
+
const showView = options.includes('view');
|
|
73
|
+
const hasDropdown = showView || enabledAssistants.length > 0;
|
|
66
74
|
---
|
|
75
|
+
{(showCopy || hasDropdown) && (
|
|
67
76
|
<div class="wd-copy-page-split">
|
|
68
|
-
|
|
69
|
-
<
|
|
70
|
-
|
|
71
|
-
|
|
77
|
+
{showCopy && (
|
|
78
|
+
<button type="button" class:list={['wd-copy-page-primary', { 'wd-copy-page-alone': !hasDropdown }]} data-md-path={mdPath} aria-label="Copy page as Markdown">
|
|
79
|
+
<Icon name="lucide:copy" class="wd-copy-page-icon" />
|
|
80
|
+
<span class="wd-copy-page-primary-label">Copy page</span>
|
|
81
|
+
</button>
|
|
82
|
+
)}
|
|
83
|
+
{hasDropdown && (
|
|
72
84
|
<div class="wd-dropdown wd-copy-page">
|
|
73
|
-
<button type="button" class=
|
|
85
|
+
<button type="button" class:list={['wd-dropdown-trigger', 'wd-copy-page-caret-trigger', { 'wd-copy-page-labeled-trigger': !showCopy }]} aria-haspopup="true" aria-expanded="false" aria-label={showCopy ? 'More copy options' : 'Page options'}>
|
|
86
|
+
{!showCopy && (
|
|
87
|
+
<>
|
|
88
|
+
<Icon name="lucide:sparkles" class="wd-copy-page-icon" />
|
|
89
|
+
<span class="wd-copy-page-primary-label">Page options</span>
|
|
90
|
+
</>
|
|
91
|
+
)}
|
|
74
92
|
<svg class="wd-dropdown-caret" width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
|
|
75
93
|
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round" stroke-linejoin="round" />
|
|
76
94
|
</svg>
|
|
77
95
|
</button>
|
|
78
96
|
<div class="wd-dropdown-menu wd-copy-page-menu" role="menu">
|
|
79
97
|
<div class="wd-dropdown-menu-panel wd-copy-page-menu-panel">
|
|
98
|
+
{showView && (
|
|
80
99
|
<a class="wd-copy-page-item" href={mdPath} target="_blank" rel="noopener">
|
|
81
100
|
<Icon name="lucide:external-link" class="wd-copy-page-icon" />
|
|
82
101
|
<span class="wd-copy-page-item-text">
|
|
@@ -84,8 +103,9 @@ const enabledAssistants = mdAbsoluteUrl
|
|
|
84
103
|
<span class="wd-copy-page-item-desc">View this page as plain text</span>
|
|
85
104
|
</span>
|
|
86
105
|
</a>
|
|
106
|
+
)}
|
|
87
107
|
{enabledAssistants.map((a) => (
|
|
88
|
-
<a class="wd-copy-page-item" href={a.
|
|
108
|
+
<a class="wd-copy-page-item" href={assistantHref(a)} data-ask-base={mdAbsoluteUrl ? undefined : a.base} data-md-path={mdAbsoluteUrl ? undefined : mdPath} target="_blank" rel="noopener">
|
|
89
109
|
<Icon name={a.icon} class="wd-copy-page-icon" />
|
|
90
110
|
<span class="wd-copy-page-item-text">
|
|
91
111
|
<span class="wd-copy-page-item-title">Open in {a.label}</span>
|
|
@@ -96,7 +116,9 @@ const enabledAssistants = mdAbsoluteUrl
|
|
|
96
116
|
</div>
|
|
97
117
|
</div>
|
|
98
118
|
</div>
|
|
119
|
+
)}
|
|
99
120
|
</div>
|
|
121
|
+
)}
|
|
100
122
|
|
|
101
123
|
<style>
|
|
102
124
|
/* The split button: a primary "Copy page" action (always visible,
|
|
@@ -174,6 +196,20 @@ const enabledAssistants = mdAbsoluteUrl
|
|
|
174
196
|
border-left: 1px solid var(--wd-border);
|
|
175
197
|
border-radius: 0 0.35rem 0.35rem 0;
|
|
176
198
|
}
|
|
199
|
+
/* Only one of the two halves: it gets all four corners (and, for the
|
|
200
|
+
trigger, no divider line and a label like the primary button's). */
|
|
201
|
+
.wd-copy-page-primary.wd-copy-page-alone {
|
|
202
|
+
border-radius: 0.35rem;
|
|
203
|
+
}
|
|
204
|
+
.wd-copy-page-caret-trigger.wd-copy-page-labeled-trigger {
|
|
205
|
+
gap: 0.35rem;
|
|
206
|
+
padding: 0.35rem 0.65rem;
|
|
207
|
+
border-left: none;
|
|
208
|
+
border-radius: 0.35rem;
|
|
209
|
+
color: var(--wd-text-muted);
|
|
210
|
+
font-size: 0.85rem;
|
|
211
|
+
font-weight: 500;
|
|
212
|
+
}
|
|
177
213
|
.wd-copy-page-menu {
|
|
178
214
|
left: auto;
|
|
179
215
|
right: 0;
|
package/src/lib/config-schema.js
CHANGED
|
@@ -390,9 +390,26 @@ const apiSchema = z.object({
|
|
|
390
390
|
// site can set this to `false` to never involve a third party.
|
|
391
391
|
proxy: z.boolean().default(true)
|
|
392
392
|
}).strict().default({ proxy: true });
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
393
|
+
const CONTEXT_MENU_OPTIONS = ["copy", "view", "chatgpt", "claude", "perplexity"];
|
|
394
|
+
const ASSISTANT_OPTIONS = ["chatgpt", "claude", "perplexity"];
|
|
395
|
+
const contextMenuSchema = z.union([
|
|
396
|
+
z.boolean(),
|
|
397
|
+
z.array(z.enum(CONTEXT_MENU_OPTIONS)),
|
|
398
|
+
z.object({
|
|
399
|
+
openIn: z.array(z.enum(ASSISTANT_OPTIONS)).optional()
|
|
400
|
+
}).strict()
|
|
401
|
+
]);
|
|
402
|
+
function contextMenuOptions(value) {
|
|
403
|
+
if (value === false) return [];
|
|
404
|
+
if (value === void 0 || value === null || value === true) return [...CONTEXT_MENU_OPTIONS];
|
|
405
|
+
if (Array.isArray(value)) return CONTEXT_MENU_OPTIONS.filter((option) => value.includes(option));
|
|
406
|
+
if (typeof value === "object") {
|
|
407
|
+
const openIn = value.openIn;
|
|
408
|
+
const assistants = Array.isArray(openIn) ? openIn : [...ASSISTANT_OPTIONS];
|
|
409
|
+
return CONTEXT_MENU_OPTIONS.filter((option) => option === "copy" || option === "view" || assistants.includes(option));
|
|
410
|
+
}
|
|
411
|
+
return [];
|
|
412
|
+
}
|
|
396
413
|
const redirectSchema = z.object({
|
|
397
414
|
source: z.string(),
|
|
398
415
|
destination: z.string()
|
|
@@ -519,8 +536,8 @@ const docsConfigSchema = z.object({
|
|
|
519
536
|
// field by field via mergeSeo(), rather than needing to repeat every
|
|
520
537
|
// field on every page.
|
|
521
538
|
seo: seoFieldsSchema.default({}),
|
|
522
|
-
// The "Copy page"
|
|
523
|
-
//
|
|
539
|
+
// The "Copy page" menu - see contextMenuSchema above. Absent means on,
|
|
540
|
+
// with every option; read it through contextMenuOptions().
|
|
524
541
|
contextMenu: contextMenuSchema.optional(),
|
|
525
542
|
// The site's MCP server - an AI tool connects to `/mcp` and searches and
|
|
526
543
|
// reads the docs. On by default: `writedocs build` writes the page index
|
|
@@ -829,7 +846,9 @@ function validateDocsConfig(rawText) {
|
|
|
829
846
|
return { ok: true, data: result.data, issues: [] };
|
|
830
847
|
}
|
|
831
848
|
export {
|
|
849
|
+
CONTEXT_MENU_OPTIONS,
|
|
832
850
|
ROOT_ALLOWED_EXTRA_KEYS,
|
|
851
|
+
contextMenuOptions,
|
|
833
852
|
createJsonLocator,
|
|
834
853
|
docsConfigSchema,
|
|
835
854
|
formatValidationIssues,
|
package/src/lib/config-schema.ts
CHANGED
|
@@ -727,25 +727,50 @@ const apiSchema = z
|
|
|
727
727
|
.strict()
|
|
728
728
|
.default({ proxy: true });
|
|
729
729
|
|
|
730
|
-
// The "Copy page"
|
|
731
|
-
// Markdown
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
//
|
|
735
|
-
//
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
//
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
730
|
+
// The "Copy page" menu shown next to a page's title - copy the page's
|
|
731
|
+
// Markdown, view it, or open it in an AI assistant - and the .md copy of
|
|
732
|
+
// every page it relies on ([...slug].md.ts). On by default, with every
|
|
733
|
+
// option, like the MCP server. writedocs.json narrows or turns it off:
|
|
734
|
+
//
|
|
735
|
+
// (absent) or true every option
|
|
736
|
+
// ["copy", "claude"] only these, in the menu's own order
|
|
737
|
+
// false (or []) no menu, and no .md routes
|
|
738
|
+
// { "openIn": [...] } the earlier form: copy and view, plus these
|
|
739
|
+
// assistants - still accepted
|
|
740
|
+
//
|
|
741
|
+
// Read it through contextMenuOptions() below - never test the raw value for
|
|
742
|
+
// truthiness: absent now means on.
|
|
743
|
+
export const CONTEXT_MENU_OPTIONS = ['copy', 'view', 'chatgpt', 'claude', 'perplexity'] as const;
|
|
744
|
+
export type ContextMenuOption = (typeof CONTEXT_MENU_OPTIONS)[number];
|
|
745
|
+
const ASSISTANT_OPTIONS = ['chatgpt', 'claude', 'perplexity'] as const;
|
|
746
|
+
|
|
747
|
+
const contextMenuSchema = z.union([
|
|
748
|
+
z.boolean(),
|
|
749
|
+
z.array(z.enum(CONTEXT_MENU_OPTIONS)),
|
|
750
|
+
z
|
|
751
|
+
.object({
|
|
752
|
+
openIn: z.array(z.enum(ASSISTANT_OPTIONS)).optional(),
|
|
753
|
+
})
|
|
754
|
+
.strict(),
|
|
755
|
+
]);
|
|
746
756
|
|
|
747
757
|
export type ContextMenuConfig = z.infer<typeof contextMenuSchema>;
|
|
748
758
|
|
|
759
|
+
/** The menu options a writedocs.json `contextMenu` value turns on, in menu
|
|
760
|
+
* order - [] when the menu is off. Takes the raw value too (link-check.js
|
|
761
|
+
* reads writedocs.json without the schema), so absent means every option. */
|
|
762
|
+
export function contextMenuOptions(value: unknown): ContextMenuOption[] {
|
|
763
|
+
if (value === false) return [];
|
|
764
|
+
if (value === undefined || value === null || value === true) return [...CONTEXT_MENU_OPTIONS];
|
|
765
|
+
if (Array.isArray(value)) return CONTEXT_MENU_OPTIONS.filter((option) => value.includes(option));
|
|
766
|
+
if (typeof value === 'object') {
|
|
767
|
+
const openIn = (value as { openIn?: unknown }).openIn;
|
|
768
|
+
const assistants = Array.isArray(openIn) ? openIn : [...ASSISTANT_OPTIONS];
|
|
769
|
+
return CONTEXT_MENU_OPTIONS.filter((option) => option === 'copy' || option === 'view' || assistants.includes(option));
|
|
770
|
+
}
|
|
771
|
+
return [];
|
|
772
|
+
}
|
|
773
|
+
|
|
749
774
|
// One entry in writedocs.json's `redirects` array - wired almost directly into
|
|
750
775
|
// Astro's own `redirects` config option (astro.config.mjs), which is what
|
|
751
776
|
// actually generates the redirect pages. `permanent` is deliberately not
|
|
@@ -996,8 +1021,8 @@ export const docsConfigSchema = z.object({
|
|
|
996
1021
|
// field by field via mergeSeo(), rather than needing to repeat every
|
|
997
1022
|
// field on every page.
|
|
998
1023
|
seo: seoFieldsSchema.default({}),
|
|
999
|
-
// The "Copy page"
|
|
1000
|
-
//
|
|
1024
|
+
// The "Copy page" menu - see contextMenuSchema above. Absent means on,
|
|
1025
|
+
// with every option; read it through contextMenuOptions().
|
|
1001
1026
|
contextMenu: contextMenuSchema.optional(),
|
|
1002
1027
|
// The site's MCP server - an AI tool connects to `/mcp` and searches and
|
|
1003
1028
|
// reads the docs. On by default: `writedocs build` writes the page index
|
package/src/lib/content-check.js
CHANGED
|
@@ -434,6 +434,21 @@ export async function checkContent(contentDir, configText) {
|
|
|
434
434
|
const { message, suggestion } = unknownIconMessage(icon);
|
|
435
435
|
warnings.push(issue('writedocs.json', locate(jsonPath)?.line, message, suggestion));
|
|
436
436
|
}
|
|
437
|
+
// No `domain`: the site builds, but everything that needs its full
|
|
438
|
+
// address goes without - flagged so it's a choice, not a surprise.
|
|
439
|
+
if (typeof config.domain !== 'string' || !config.domain.trim()) {
|
|
440
|
+
// `code` lets `writedocs convert` leave it out - it gives its own
|
|
441
|
+
// domain hint, with where the source tool kept that setting.
|
|
442
|
+
warnings.push({
|
|
443
|
+
code: 'no-domain',
|
|
444
|
+
...issue(
|
|
445
|
+
'writedocs.json',
|
|
446
|
+
undefined,
|
|
447
|
+
'No "domain" set - there\'s no sitemap.xml, and llms.txt, the MCP server\'s page URLs and the "Open in…" links in the page menu use relative addresses or the address the page is opened from.',
|
|
448
|
+
'Set "domain" to the site\'s address, like "docs.example.com".'
|
|
449
|
+
),
|
|
450
|
+
});
|
|
451
|
+
}
|
|
437
452
|
}
|
|
438
453
|
await checkOpenApi(contentDir, config, config ? createJsonLocator(configText) : null, openapiRefs, errors, warnings);
|
|
439
454
|
|
|
@@ -164,8 +164,8 @@ export const DESCRIPTIONS = {
|
|
|
164
164
|
'seo.twitterCard': 'X/Twitter card style. Default "summary_large_image" with an `ogImage`, "summary" without.',
|
|
165
165
|
'seo.keywords': 'Keywords for the <meta name="keywords"> tag.',
|
|
166
166
|
'seo.noindex': 'Ask search engines not to index pages, and leave them out of sitemap.xml.',
|
|
167
|
-
contextMenu: '
|
|
168
|
-
'contextMenu.openIn': '
|
|
167
|
+
contextMenu: 'The "Copy page" menu on every page - copy as Markdown, view as Markdown, open in ChatGPT, Claude or Perplexity - and a Markdown copy of each page at its address + ".md". On by default with every option. A list picks the options ("copy", "view", "chatgpt", "claude", "perplexity"); false turns it all off.',
|
|
168
|
+
'contextMenu.openIn': 'The earlier form: which AI assistants the menu offers, besides copy and view. A list of options replaces it.',
|
|
169
169
|
mcp: 'An MCP server at /mcp, so AI tools can search and read the docs. The build adds its index and a Cloudflare-ready _worker.js to dist/. Default true; false leaves them out.',
|
|
170
170
|
redirects: 'Redirects from old addresses. Each matches one exact path.',
|
|
171
171
|
'redirects[].source': 'The old path, like "/old-page".',
|
package/src/lib/link-check.js
CHANGED
|
@@ -33,7 +33,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
33
33
|
import matter from 'gray-matter';
|
|
34
34
|
import { visit } from 'unist-util-visit';
|
|
35
35
|
import { findAllPages } from './pages.js';
|
|
36
|
-
import { createJsonLocator } from './config-schema.js';
|
|
36
|
+
import { createJsonLocator, contextMenuOptions } from './config-schema.js';
|
|
37
37
|
import { writedocsTempDir } from './writedocs-temp-dir.js';
|
|
38
38
|
|
|
39
39
|
// The same github-slugger Astro builds page URLs and heading ids with -
|
|
@@ -423,7 +423,7 @@ export async function checkLinks(contentDir, configText) {
|
|
|
423
423
|
// A link to a page's source file (docs/setup.mdx) instead of its URL.
|
|
424
424
|
if (extension === '.mdx' || extension === '.md') {
|
|
425
425
|
const bare = pathname.replace(/\.mdx?$/i, '').replace(/\/?$/, '/');
|
|
426
|
-
if (extension === '.md' && config.contextMenu && pages.has(bare)) return; // the page's Markdown copy
|
|
426
|
+
if (extension === '.md' && contextMenuOptions(config.contextMenu).length && pages.has(bare)) return; // the page's Markdown copy (on unless "contextMenu": false)
|
|
427
427
|
// Written as a file path, so look it up as one: from the file's own
|
|
428
428
|
// folder when relative.
|
|
429
429
|
const filePath = isRelative && fileAbs ? path.resolve(path.dirname(fileAbs), decodeURI(value.split(/[?#]/)[0])) : path.join(contentDir, pathname);
|
package/src/lib/llms-index.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { getCollection, type CollectionEntry } from 'astro:content';
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
|
-
import { loadDocsConfig, normalizeEntryId, findAllPages, resolveSiteUrl, fileIdForEntry } from './config';
|
|
9
|
+
import { loadDocsConfig, normalizeEntryId, findAllPages, resolveSiteUrl, fileIdForEntry, contextMenuOptions } from './config';
|
|
10
10
|
import { buildLlmsTree, renderLlmsFiles } from './llms.js';
|
|
11
11
|
import { navigationPageOrder } from './pages.js';
|
|
12
12
|
import { writedocsTempDir } from './writedocs-temp-dir.js';
|
|
@@ -59,10 +59,10 @@ export async function llmsIndexFiles(): Promise<Map<string, string> | null> {
|
|
|
59
59
|
// page (Mintlify's external link) has no content of its own.
|
|
60
60
|
if (entry.data.seo?.noindex || entry.data.url) continue;
|
|
61
61
|
const slug = normalizeEntryId(entry.id);
|
|
62
|
-
// The .md route when it exists ([...slug].md.ts -
|
|
62
|
+
// The .md route when it exists ([...slug].md.ts - unless `contextMenu` is off,
|
|
63
63
|
// and never for an OpenAPI page, which renders from the spec), else the
|
|
64
64
|
// HTML page.
|
|
65
|
-
const hasMarkdownRoute = config.contextMenu && !entry.data.openapi;
|
|
65
|
+
const hasMarkdownRoute = contextMenuOptions(config.contextMenu).length > 0 && !entry.data.openapi;
|
|
66
66
|
const href = (siteUrl ?? '') + (hasMarkdownRoute ? `/${slug}.md` : slug === 'index' ? '/' : `/${slug}/`);
|
|
67
67
|
let description = truncateDescription(entry.data.description);
|
|
68
68
|
// Mirrors Mintlify: an OpenAPI operation page's description gets its
|
|
@@ -530,13 +530,16 @@ export function convertMintlifyConfig(docs) {
|
|
|
530
530
|
if (docs.seo?.indexing === 'all') notes.add('seo.indexing', ['seo', 'indexing'], '`seo.indexing: "all"` has no equivalent - writedocs indexes every page that isn\'t marked noindex.');
|
|
531
531
|
|
|
532
532
|
// context menu
|
|
533
|
+
// Mintlify's option names are writedocs' own for the five both have, so the
|
|
534
|
+
// list carries over as is. No `contextual` leaves the field out - the menu
|
|
535
|
+
// is on by default, with every option.
|
|
533
536
|
const options = docs.contextual?.options;
|
|
534
537
|
if (Array.isArray(options) && options.length) {
|
|
535
|
-
const
|
|
536
|
-
out.contextMenu =
|
|
537
|
-
const other = options.filter((o) => typeof o !== 'string' || !
|
|
538
|
+
const known = ['copy', 'view', 'chatgpt', 'claude', 'perplexity'];
|
|
539
|
+
out.contextMenu = known.filter((o) => options.includes(o));
|
|
540
|
+
const other = options.filter((o) => typeof o !== 'string' || !known.includes(o));
|
|
538
541
|
if (other.length) {
|
|
539
|
-
notes.add('contextual', ['contextual', 'options'], `Context menu options writedocs doesn't have were dropped: ${other.map((o) => (typeof o === 'string' ? o : o.title ?? 'custom')).join(', ')}.`, 'writedocs\' page menu
|
|
542
|
+
notes.add('contextual', ['contextual', 'options'], `Context menu options writedocs doesn't have were dropped: ${other.map((o) => (typeof o === 'string' ? o : o.title ?? 'custom')).join(', ')}.`, 'writedocs\' page menu offers copy, view as Markdown, and open in ChatGPT, Claude and Perplexity.');
|
|
540
543
|
}
|
|
541
544
|
}
|
|
542
545
|
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
flattenNav,
|
|
17
17
|
firstSlugOfNavigation,
|
|
18
18
|
mergeSeo,
|
|
19
|
+
contextMenuOptions,
|
|
19
20
|
resolveSiteUrl,
|
|
20
21
|
findAllPages,
|
|
21
22
|
} from "../lib/config";
|
|
@@ -414,8 +415,9 @@ const showToc = pageMode === "default";
|
|
|
414
415
|
// branch on separately.
|
|
415
416
|
const isCanvasMode = pageMode === "custom" || pageMode === "blank";
|
|
416
417
|
|
|
417
|
-
// The "Copy page"
|
|
418
|
-
// `contextMenu`
|
|
418
|
+
// The "Copy page" menu (CopyPageMenu.astro) - on by default, with the
|
|
419
|
+
// options writedocs.json's `contextMenu` leaves on (contextMenuOptions() in
|
|
420
|
+
// lib/config-schema.ts; `false` turns it off), and only
|
|
419
421
|
// shown where there's a real auto-rendered <h1> to sit next to: canvas
|
|
420
422
|
// mode pages (custom/blank) have no such header (a hand-built landing
|
|
421
423
|
// page controls its own layout, there's nothing standard to anchor the
|
|
@@ -423,7 +425,8 @@ const isCanvasMode = pageMode === "custom" || pageMode === "blank";
|
|
|
423
425
|
// spec rather than prose - see [...slug].md.ts's own comment on why
|
|
424
426
|
// those are excluded from the .md route this menu links to in the first
|
|
425
427
|
// place, which this mirrors on the UI side.
|
|
426
|
-
const
|
|
428
|
+
const contextMenuItems = contextMenuOptions(config.contextMenu);
|
|
429
|
+
const showCopyPageMenu = contextMenuItems.length > 0 && !isCanvasMode && !entry.data.openapi;
|
|
427
430
|
const siteUrl = resolveSiteUrl(config);
|
|
428
431
|
|
|
429
432
|
const components = {
|
|
@@ -513,7 +516,7 @@ const components = {
|
|
|
513
516
|
)
|
|
514
517
|
}
|
|
515
518
|
{showCopyPageMenu && (
|
|
516
|
-
<CopyPageMenu currentPath={currentPath} siteUrl={siteUrl}
|
|
519
|
+
<CopyPageMenu currentPath={currentPath} siteUrl={siteUrl} options={contextMenuItems} />
|
|
517
520
|
)}
|
|
518
521
|
</div>
|
|
519
522
|
<Content components={components} />
|
|
@@ -672,6 +675,19 @@ const components = {
|
|
|
672
675
|
initCopyPageMenu(document);
|
|
673
676
|
document.addEventListener("astro:page-load", () => initCopyPageMenu(document));
|
|
674
677
|
|
|
678
|
+
// "Open in ChatGPT/Claude/Perplexity" on a site with no writedocs.json
|
|
679
|
+
// `domain`: the page's absolute .md URL isn't known at build time, so the
|
|
680
|
+
// link is completed here from the address the page is served from (see
|
|
681
|
+
// CopyPageMenu.astro) - same prompt the build writes when it does know.
|
|
682
|
+
function initAskLinks(root: ParentNode) {
|
|
683
|
+
root.querySelectorAll<HTMLAnchorElement>("a[data-ask-base][data-md-path]").forEach((link) => {
|
|
684
|
+
const url = new URL(link.dataset.mdPath ?? "/", window.location.origin).href;
|
|
685
|
+
link.href = link.dataset.askBase + encodeURIComponent(`Read ${url} so you can answer questions about it.`);
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
initAskLinks(document);
|
|
689
|
+
document.addEventListener("astro:page-load", () => initAskLinks(document));
|
|
690
|
+
|
|
675
691
|
// Show more/less toggle for ```js expandable code blocks - same
|
|
676
692
|
// build-time-emitted-button + client-wired-click pattern as the copy
|
|
677
693
|
// button above; codeBlockTransformer only adds this button when the
|
|
@@ -845,7 +861,7 @@ const components = {
|
|
|
845
861
|
width: 100%;
|
|
846
862
|
}
|
|
847
863
|
/* Wraps the auto-rendered <h1> together with CopyPageMenu (only
|
|
848
|
-
rendered when writedocs.json's `contextMenu`
|
|
864
|
+
rendered when writedocs.json's `contextMenu` isn't off - see
|
|
849
865
|
showCopyPageMenu above) so the two sit on one row, menu pinned to
|
|
850
866
|
the right. Always present (even with no menu) rather than only
|
|
851
867
|
wrapping the <h1> conditionally, so the <h1>'s own top/bottom
|
|
@@ -2,7 +2,7 @@ import { getCollection, type CollectionEntry } from 'astro:content';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import type { APIRoute } from 'astro';
|
|
5
|
-
import { loadDocsConfig, normalizeEntryId, findAllPages } from '../lib/config';
|
|
5
|
+
import { loadDocsConfig, normalizeEntryId, findAllPages, contextMenuOptions } from '../lib/config';
|
|
6
6
|
import { writedocsTempDir } from '../lib/writedocs-temp-dir.js';
|
|
7
7
|
import { markdownForAgents } from '../lib/agent-markdown.js';
|
|
8
8
|
|
|
@@ -11,10 +11,10 @@ import { markdownForAgents } from '../lib/agent-markdown.js';
|
|
|
11
11
|
// /guides/foo/ -> /guides/foo.md), serving its untouched MDX/Markdown
|
|
12
12
|
// source instead of rendered HTML - what the "Copy page"/"View as
|
|
13
13
|
// Markdown" menu (CopyPageMenu.astro, wired in from [...slug].astro)
|
|
14
|
-
// links to, and what an LLM fetching that URL directly gets.
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
14
|
+
// links to, and what an LLM fetching that URL directly gets. On by
|
|
15
|
+
// default, like the menu; writedocs.json `"contextMenu": false` (see
|
|
16
|
+
// contextMenuOptions() in lib/config-schema.ts) leaves out the .md routes
|
|
17
|
+
// too, not just the menu.
|
|
18
18
|
//
|
|
19
19
|
// A literal ".md" filename suffix rather than a `[...slug]` capture
|
|
20
20
|
// covering it: naming the file `[...slug].md.ts` makes Astro treat ".md"
|
|
@@ -33,9 +33,9 @@ type DocsEntry = CollectionEntry<'pages'> | CollectionEntry<'generatedDocs'>;
|
|
|
33
33
|
export async function getStaticPaths() {
|
|
34
34
|
const contentDir = process.env.WRITEDOCS_CONTENT_DIR || process.cwd();
|
|
35
35
|
const config = loadDocsConfig(contentDir);
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
if (
|
|
36
|
+
// `"contextMenu": false` - the feature is off entirely, these routes
|
|
37
|
+
// included (not just the menu UI - see the file-level comment above).
|
|
38
|
+
if (contextMenuOptions(config.contextMenu).length === 0) return [];
|
|
39
39
|
|
|
40
40
|
const hasGeneratedDocs = fs.existsSync(path.join(writedocsTempDir(contentDir), 'generated-docs'));
|
|
41
41
|
const hasPages = findAllPages(contentDir).length > 0;
|
package/writedocs.schema.json
CHANGED
|
@@ -747,30 +747,45 @@
|
|
|
747
747
|
"markdownDescription": "Default metadata for every page. A page's own `seo` frontmatter overrides it field by field."
|
|
748
748
|
},
|
|
749
749
|
"contextMenu": {
|
|
750
|
-
"
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
"claude",
|
|
756
|
-
"perplexity"
|
|
757
|
-
],
|
|
750
|
+
"anyOf": [
|
|
751
|
+
{
|
|
752
|
+
"type": "boolean"
|
|
753
|
+
},
|
|
754
|
+
{
|
|
758
755
|
"type": "array",
|
|
759
756
|
"items": {
|
|
760
757
|
"type": "string",
|
|
761
758
|
"enum": [
|
|
759
|
+
"copy",
|
|
760
|
+
"view",
|
|
762
761
|
"chatgpt",
|
|
763
762
|
"claude",
|
|
764
763
|
"perplexity"
|
|
765
764
|
]
|
|
765
|
+
}
|
|
766
|
+
},
|
|
767
|
+
{
|
|
768
|
+
"type": "object",
|
|
769
|
+
"properties": {
|
|
770
|
+
"openIn": {
|
|
771
|
+
"type": "array",
|
|
772
|
+
"items": {
|
|
773
|
+
"type": "string",
|
|
774
|
+
"enum": [
|
|
775
|
+
"chatgpt",
|
|
776
|
+
"claude",
|
|
777
|
+
"perplexity"
|
|
778
|
+
]
|
|
779
|
+
},
|
|
780
|
+
"description": "The earlier form: which AI assistants the menu offers, besides copy and view. A list of options replaces it.",
|
|
781
|
+
"markdownDescription": "The earlier form: which AI assistants the menu offers, besides copy and view. A list of options replaces it."
|
|
782
|
+
}
|
|
766
783
|
},
|
|
767
|
-
"
|
|
768
|
-
"markdownDescription": "Which AI assistants the menu offers. Default: all three."
|
|
784
|
+
"additionalProperties": false
|
|
769
785
|
}
|
|
770
|
-
|
|
771
|
-
"
|
|
772
|
-
"
|
|
773
|
-
"markdownDescription": "Adds a \"Copy page\" menu to pages - copy as Markdown, or open the page in an AI assistant - and a Markdown copy of each page at its address + \".md\"."
|
|
786
|
+
],
|
|
787
|
+
"description": "The \"Copy page\" menu on every page - copy as Markdown, view as Markdown, open in ChatGPT, Claude or Perplexity - and a Markdown copy of each page at its address + \".md\". On by default with every option. A list picks the options (\"copy\", \"view\", \"chatgpt\", \"claude\", \"perplexity\"); false turns it all off.",
|
|
788
|
+
"markdownDescription": "The \"Copy page\" menu on every page - copy as Markdown, view as Markdown, open in ChatGPT, Claude or Perplexity - and a Markdown copy of each page at its address + \".md\". On by default with every option. A list picks the options (\"copy\", \"view\", \"chatgpt\", \"claude\", \"perplexity\"); false turns it all off."
|
|
774
789
|
},
|
|
775
790
|
"mcp": {
|
|
776
791
|
"default": true,
|