@shoalrun/cli 0.1.0-alpha.18 → 0.1.0-alpha.19
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/CHANGELOG.md +31 -0
- package/README.md +52 -27
- package/agent-docs/.well-known/shoalrun/navigation/project-updates-50f26fe22670.txt +3 -1
- package/agent-docs/SNAPSHOT.json +5 -5
- package/agent-docs/changelog/branding-security.md +66 -0
- package/agent-docs/changelog.md +8 -0
- package/agent-docs/chat/host.md +20 -2
- package/agent-docs/chat/settings.md +1 -1
- package/agent-docs/chat/settings.sections/configure-chat.md +1 -1
- package/agent-docs/config/examples.md +2 -2
- package/agent-docs/config/examples.sections/feature-showcase.md +2 -2
- package/agent-docs/config/examples.sections/minimal-site.md +1 -1
- package/agent-docs/config/examples.sections/multilingual-help-center.md +1 -1
- package/agent-docs/config/examples.sections/parcel-api-documentation.md +1 -1
- package/agent-docs/config/examples.sections/related-reading.md +1 -1
- package/agent-docs/config/examples.sections/shared-company-preset.md +1 -1
- package/agent-docs/configuration.md +1 -1
- package/agent-docs/configuration.sections/start-with-one-working-file.md +1 -1
- package/agent-docs/documents.json +158 -64
- package/agent-docs/provenance.json +6 -6
- package/agent-docs/search-index.json +1 -1
- package/agent-docs/start.md +1 -1
- package/agent-docs/start.sections/start-your-own-project.md +1 -1
- package/assets/ask-widget.js +1 -1
- package/dist/ask-function.js +5 -2
- package/dist/chat-config.d.ts +2 -0
- package/dist/chat-config.js +10 -2
- package/dist/cli.js +2 -2
- package/dist/turnstile-policy.d.ts +2 -0
- package/dist/turnstile-policy.js +23 -0
- package/dist/turnstile.d.ts +7 -0
- package/dist/turnstile.js +29 -2
- package/dist/vercel-function.js +1 -1
- package/package.json +1 -1
- package/skills/shoalrun-brand/SKILL.md +52 -0
package/agent-docs/start.md
CHANGED
|
@@ -36,7 +36,7 @@ Install an exact version of the alpha and initialize the minimal starter:
|
|
|
36
36
|
mkdir my-docs
|
|
37
37
|
cd my-docs
|
|
38
38
|
npm init -y
|
|
39
|
-
npm install --save-exact @shoalrun/cli@0.1.0-alpha.
|
|
39
|
+
npm install --save-exact @shoalrun/cli@0.1.0-alpha.19
|
|
40
40
|
npx --no-install shoalrun init .
|
|
41
41
|
```
|
|
42
42
|
|
|
@@ -38,7 +38,7 @@ Install an exact version of the alpha and initialize the minimal starter:
|
|
|
38
38
|
mkdir my-docs
|
|
39
39
|
cd my-docs
|
|
40
40
|
npm init -y
|
|
41
|
-
npm install --save-exact @shoalrun/cli@0.1.0-alpha.
|
|
41
|
+
npm install --save-exact @shoalrun/cli@0.1.0-alpha.19
|
|
42
42
|
npx --no-install shoalrun init .
|
|
43
43
|
```
|
|
44
44
|
|
package/assets/ask-widget.js
CHANGED
|
@@ -252,7 +252,7 @@
|
|
|
252
252
|
});
|
|
253
253
|
const checkVisitor = async () => {
|
|
254
254
|
const turnstile = await loadTurnstile();
|
|
255
|
-
if (widget === undefined) widget = turnstile.render(challengeBox, { sitekey: hosted.siteKey, appearance: 'interaction-only', execution: 'execute', callback: token => waiter?.resolve(token), 'error-callback': () => waiter?.reject(new Error('The check did not pass.')) });
|
|
255
|
+
if (widget === undefined) widget = turnstile.render(challengeBox, { sitekey: hosted.siteKey, ...(hosted.action ? { action: hosted.action } : {}), appearance: 'interaction-only', execution: 'execute', callback: token => waiter?.resolve(token), 'error-callback': () => waiter?.reject(new Error('The check did not pass.')) });
|
|
256
256
|
else turnstile.reset(widget);
|
|
257
257
|
return new Promise((resolve, reject) => {
|
|
258
258
|
const timer = setTimeout(() => reject(new Error('The check timed out.')), 30000);
|
package/dist/ask-function.js
CHANGED
|
@@ -14,6 +14,7 @@ import { createSession, turnstileVerifier } from './turnstile.js';
|
|
|
14
14
|
import { envRedis } from './budget-store.js';
|
|
15
15
|
import { pausedKey } from './chat-pause.js';
|
|
16
16
|
import { deleteKvLogRecord, kvLogSink } from './ask-log-kv.js';
|
|
17
|
+
import { turnstileHostnames } from './turnstile-policy.js';
|
|
17
18
|
// The answer function for a hosted site. The build writes a directory holding `runtime.json` (the chat and retrieval settings, with no
|
|
18
19
|
// keys) and `site/ai` (the passages and vectors), and this module turns that directory into a Web `Request` to `Response` function. Keys come
|
|
19
20
|
// only from the environment variables the settings name. It refuses to start without a shared budget store, because per-instance counters
|
|
@@ -39,8 +40,10 @@ export async function createAskFunction(directory, env = process.env, send) {
|
|
|
39
40
|
if (chat.challenge) {
|
|
40
41
|
const secret = env[chat.challenge.secretEnv];
|
|
41
42
|
if (secret) {
|
|
42
|
-
|
|
43
|
-
const
|
|
43
|
+
const allowedHostnames = turnstileHostnames(chat.challenge.hostnames ?? hosts.map(host => new URL(`https://${host}`).hostname), 'Turnstile allowedHostnames');
|
|
44
|
+
const context = JSON.stringify({ version: 1, hostnames: [...allowedHostnames].sort(), action: chat.challenge.action ?? null });
|
|
45
|
+
session = createSession({ secret, context });
|
|
46
|
+
const verify = turnstileVerifier({ secret, allowedHostnames, ...(chat.challenge.action ? { action: chat.challenge.action } : {}), ...(send ? { fetch: send } : {}) });
|
|
44
47
|
challenge = ({ token, ip }) => verify({ token, ip });
|
|
45
48
|
}
|
|
46
49
|
else {
|
package/dist/chat-config.d.ts
CHANGED
package/dist/chat-config.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { reservedBodyFields } from './chat.js';
|
|
2
2
|
import { validateReaderSelection } from './reader-filters.js';
|
|
3
|
+
import { turnstileHostnames } from './turnstile-policy.js';
|
|
3
4
|
export const escalateReasons = ['unsupported-claim', 'unsupported-code', 'uncited'];
|
|
4
5
|
export const defaultLogNotice = 'Questions are recorded, with personal details removed, to improve these docs.';
|
|
5
6
|
const mapping = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
@@ -305,9 +306,9 @@ export function validateChat(value, readerFilters) {
|
|
|
305
306
|
if (!mapping(challenge))
|
|
306
307
|
throw new Error('chat.challenge must be a mapping with provider, siteKey, and secretEnv');
|
|
307
308
|
for (const key of Object.keys(challenge)) {
|
|
308
|
-
if (['provider', 'siteKey', 'secretEnv', 'when', 'freeQuestions', 'busyShare'].includes(key))
|
|
309
|
+
if (['provider', 'siteKey', 'secretEnv', 'hostnames', 'action', 'when', 'freeQuestions', 'busyShare'].includes(key))
|
|
309
310
|
continue;
|
|
310
|
-
throw new Error(/key|secret|token|password/i.test(key) ? `chat.challenge.${key} is not a setting. The config never holds a secret; name the environment variable that does with secretEnv` : `chat.challenge has an unknown setting ${key}; it accepts provider, siteKey, secretEnv, when, freeQuestions, and busyShare`);
|
|
311
|
+
throw new Error(/key|secret|token|password/i.test(key) ? `chat.challenge.${key} is not a setting. The config never holds a secret; name the environment variable that does with secretEnv` : `chat.challenge has an unknown setting ${key}; it accepts provider, siteKey, secretEnv, hostnames, action, when, freeQuestions, and busyShare`);
|
|
311
312
|
}
|
|
312
313
|
if (challenge.provider !== 'turnstile')
|
|
313
314
|
throw new Error('chat.challenge.provider must be turnstile');
|
|
@@ -322,6 +323,13 @@ export function validateChat(value, readerFilters) {
|
|
|
322
323
|
if (challenge.busyShare !== undefined && (typeof challenge.busyShare !== 'number' || !(challenge.busyShare > 0 && challenge.busyShare <= 1)))
|
|
323
324
|
throw new Error('chat.challenge.busyShare must be a number above 0 and at most 1');
|
|
324
325
|
result.challenge = { provider: 'turnstile', siteKey, secretEnv, when: challenge.when ?? 'adaptive', freeQuestions: challenge.freeQuestions === undefined ? 3 : integer(challenge.freeQuestions, 'chat.challenge.freeQuestions', 0, 1000), busyShare: challenge.busyShare ?? 0.5 };
|
|
326
|
+
if (challenge.hostnames !== undefined)
|
|
327
|
+
result.challenge.hostnames = turnstileHostnames(challenge.hostnames, 'chat.challenge.hostnames');
|
|
328
|
+
if (challenge.action !== undefined) {
|
|
329
|
+
if (typeof challenge.action !== 'string' || !/^[A-Za-z0-9_-]{1,32}$/.test(challenge.action))
|
|
330
|
+
throw new Error('chat.challenge.action must contain 1 to 32 letters, numbers, underscores, or hyphens');
|
|
331
|
+
result.challenge.action = challenge.action;
|
|
332
|
+
}
|
|
325
333
|
}
|
|
326
334
|
if (value.budget !== undefined) {
|
|
327
335
|
if (!mapping(value.budget))
|
package/dist/cli.js
CHANGED
|
@@ -40,7 +40,7 @@ import { buildChunkIndex, embeddingInput } from './index-build.js';
|
|
|
40
40
|
import { createProvider, embedChunkIndex, readEmbeddings } from './embeddings.js';
|
|
41
41
|
import { embeddingProviderConfig, queryProviderConfig, validateRetrieval } from './retrieval-config.js';
|
|
42
42
|
import { defaultLogNotice, validateChat } from './chat-config.js';
|
|
43
|
-
import { createSession,
|
|
43
|
+
import { createSession, turnstilePreviewVerifier } from './turnstile.js';
|
|
44
44
|
import { adaptiveChallenge } from './budget.js';
|
|
45
45
|
import { validateReaderFilters } from './reader-filters.js';
|
|
46
46
|
import { askQuestion } from './ask.js';
|
|
@@ -269,7 +269,7 @@ try {
|
|
|
269
269
|
if (publicPreview) {
|
|
270
270
|
chatConfigForPreview = chatConfig;
|
|
271
271
|
publicSettings = { siteKey: '1x00000000000000000000AA', ...(chatConfig?.challenge?.when === 'always' ? { prewarm: true } : {}), notice: chatConfig?.log?.notice ?? defaultLogNotice, ...(chatConfig?.panel ? { panel: chatConfig.panel } : {}) };
|
|
272
|
-
const verify =
|
|
272
|
+
const verify = turnstilePreviewVerifier();
|
|
273
273
|
challenge = ({ token, ip }) => verify({ token, ip });
|
|
274
274
|
}
|
|
275
275
|
const handler = createAskHandler({ hosts, exposeErrors: true, ...(publicPreview ? { response: 'public', challenge, session: createSession({ secret: '1x0000000000000000000000000000000AA' }), ...(chatConfigForPreview?.challenge?.when === 'always' || !setup.budget ? {} : { challengeNeeded: adaptiveChallenge(setup.budget, { freeQuestions: chatConfigForPreview?.challenge?.freeQuestions, busyShare: chatConfigForPreview?.challenge?.busyShare }) }) } : {}), ...(log ? { record: event => log.write(event) } : {}), ...(setup.budget ? { budget: setup.budget } : {}), answer: (question, history, context) => askQuestion({ question, history, ...setup.options, modelAllowed: context.modelAllowed, signal: context.signal, ...(publicPreview ? { requireCitations: true } : {}) }) });
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Canonical, exact hostnames only: no wildcard, URL, credentials, or port.
|
|
2
|
+
export function turnstileHostname(value) {
|
|
3
|
+
if (typeof value !== 'string' || !value || value.length > 253 || /[\s*]/.test(value))
|
|
4
|
+
return;
|
|
5
|
+
const hostname = value.toLowerCase().replace(/\.$/, '');
|
|
6
|
+
try {
|
|
7
|
+
const url = new URL(`https://${hostname}`);
|
|
8
|
+
if (url.host !== hostname || url.pathname !== '/' || url.search || url.hash || url.username || url.password)
|
|
9
|
+
return;
|
|
10
|
+
return hostname;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function turnstileHostnames(value, setting) {
|
|
17
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > 100)
|
|
18
|
+
throw new Error(`${setting} must be a nonempty list of at most 100 exact hostnames`);
|
|
19
|
+
const names = value.map(turnstileHostname);
|
|
20
|
+
if (names.some(name => name === undefined))
|
|
21
|
+
throw new Error(`${setting} must contain exact hostnames without wildcards, URLs, or ports`);
|
|
22
|
+
return [...new Set(names)];
|
|
23
|
+
}
|
package/dist/turnstile.d.ts
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
export type TurnstileOptions = {
|
|
2
2
|
secret: string;
|
|
3
|
+
allowedHostnames: readonly string[];
|
|
4
|
+
action?: string;
|
|
3
5
|
timeoutMs?: number;
|
|
4
6
|
fetch?: typeof fetch;
|
|
5
7
|
};
|
|
6
8
|
export declare const turnstileVerifyUrl = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
|
9
|
+
export declare function turnstilePreviewVerifier(options?: Pick<TurnstileOptions, 'fetch' | 'timeoutMs'>): (input: {
|
|
10
|
+
token?: string;
|
|
11
|
+
ip?: string;
|
|
12
|
+
}) => Promise<boolean>;
|
|
7
13
|
export declare function turnstileVerifier(options: TurnstileOptions): (input: {
|
|
8
14
|
token?: string;
|
|
9
15
|
ip?: string;
|
|
10
16
|
}) => Promise<boolean>;
|
|
11
17
|
export type SessionOptions = {
|
|
12
18
|
secret: string;
|
|
19
|
+
context?: string;
|
|
13
20
|
maxAgeSeconds?: number;
|
|
14
21
|
now?: () => number;
|
|
15
22
|
};
|
package/dist/turnstile.js
CHANGED
|
@@ -1,8 +1,31 @@
|
|
|
1
1
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { turnstileHostname, turnstileHostnames } from './turnstile-policy.js';
|
|
2
3
|
export const turnstileVerifyUrl = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
|
4
|
+
const previewSecret = '1x0000000000000000000000000000000AA';
|
|
5
|
+
const previewToken = 'XXXX.DUMMY.TOKEN.XXXX';
|
|
6
|
+
// Cloudflare's published test pair returns synthetic hostname metadata rather
|
|
7
|
+
// than a real browser hostname. This helper is used only by loopback preview.
|
|
8
|
+
export function turnstilePreviewVerifier(options = {}) {
|
|
9
|
+
const send = options.fetch ?? fetch;
|
|
10
|
+
const verify = turnstileVerifier({ ...options, secret: previewSecret, allowedHostnames: ['example.com'], fetch: async (url, init) => {
|
|
11
|
+
const response = await send(url, init);
|
|
12
|
+
if (!response.ok)
|
|
13
|
+
return response;
|
|
14
|
+
const result = await response.json();
|
|
15
|
+
return Response.json({ success: result.success === true && result.metadata?.result_with_testing_key === true, hostname: result.hostname });
|
|
16
|
+
} });
|
|
17
|
+
return async (input) => {
|
|
18
|
+
if (input.token !== previewToken)
|
|
19
|
+
return false;
|
|
20
|
+
return verify(input);
|
|
21
|
+
};
|
|
22
|
+
}
|
|
3
23
|
export function turnstileVerifier(options) {
|
|
4
24
|
if (!options.secret)
|
|
5
25
|
throw new Error('Turnstile needs a secret key');
|
|
26
|
+
const allowed = new Set(turnstileHostnames(options.allowedHostnames, 'Turnstile allowedHostnames'));
|
|
27
|
+
if (options.action !== undefined && !/^[A-Za-z0-9_-]{1,32}$/.test(options.action))
|
|
28
|
+
throw new Error('Turnstile action must contain 1 to 32 letters, numbers, underscores, or hyphens');
|
|
6
29
|
const send = options.fetch ?? fetch;
|
|
7
30
|
return async ({ token, ip }) => {
|
|
8
31
|
// Tokens are short. A missing or oversized one is refused without asking Cloudflare.
|
|
@@ -16,7 +39,8 @@ export function turnstileVerifier(options) {
|
|
|
16
39
|
if (!response.ok)
|
|
17
40
|
return false;
|
|
18
41
|
const body = await response.json();
|
|
19
|
-
|
|
42
|
+
const hostname = turnstileHostname(body?.hostname);
|
|
43
|
+
return body?.success === true && hostname !== undefined && allowed.has(hostname) && (options.action === undefined || body.action === options.action);
|
|
20
44
|
}
|
|
21
45
|
catch {
|
|
22
46
|
return false;
|
|
@@ -30,7 +54,10 @@ export function createSession(options) {
|
|
|
30
54
|
const maxAge = options.maxAgeSeconds ?? 900;
|
|
31
55
|
const name = 'sr_ask';
|
|
32
56
|
// A separate key, so the cookie signature is never the Turnstile secret itself.
|
|
33
|
-
const
|
|
57
|
+
const derivation = createHmac('sha256', options.secret).update('shoalrun-ask-session');
|
|
58
|
+
if (options.context !== undefined)
|
|
59
|
+
derivation.update('\0').update(options.context);
|
|
60
|
+
const key = derivation.digest();
|
|
34
61
|
const sign = (expires, caller) => createHmac('sha256', key).update(`${expires}.${caller}`).digest('base64url');
|
|
35
62
|
return {
|
|
36
63
|
name,
|
package/dist/vercel-function.js
CHANGED
|
@@ -83,7 +83,7 @@ export async function addAskFunction(input) {
|
|
|
83
83
|
await mkdir(shoalrun, { recursive: true });
|
|
84
84
|
const assets = path.join(input.distDirectory ?? path.dirname(fileURLToPath(import.meta.url)), '..', 'assets');
|
|
85
85
|
await cp(path.join(assets, 'ask-widget.js'), path.join(shoalrun, 'ask.js'));
|
|
86
|
-
await writeFile(path.join(shoalrun, 'chat.json'), JSON.stringify({ ...(chat.challenge ? { siteKey: chat.challenge.siteKey, ...(chat.challenge.when === 'always' ? { prewarm: true } : {}) } : {}), ...(chat.log ? { notice: chat.log.notice } : {}), ...(chat.panel ? { panel: chat.panel } : {}) }, null, 2) + '\n');
|
|
86
|
+
await writeFile(path.join(shoalrun, 'chat.json'), JSON.stringify({ ...(chat.challenge ? { siteKey: chat.challenge.siteKey, ...(chat.challenge.action ? { action: chat.challenge.action } : {}), ...(chat.challenge.when === 'always' ? { prewarm: true } : {}) } : {}), ...(chat.log ? { notice: chat.log.notice } : {}), ...(chat.panel ? { panel: chat.panel } : {}) }, null, 2) + '\n');
|
|
87
87
|
await addPanelToPages(input.staticDirectory, basePath);
|
|
88
88
|
const publications = settings.publications?.defaults;
|
|
89
89
|
const origin = settings.site?.origin;
|
package/package.json
CHANGED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: shoalrun-brand
|
|
3
|
+
description: Apply a visual brand to a Shoalrun documentation site or local demo using theme settings, fonts, logos, and scoped CSS. Use for matching a supplied website or brand guide, or refining an existing site's visual identity.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Brand a documentation site
|
|
7
|
+
|
|
8
|
+
Inspect the target project's configuration, styles, assets, installed package version, and applicable project instructions. Preserve documentation, routes, navigation, publication selection, and existing edits. For a separate demo, copy source into a new directory and keep its build output separate. Visual branding doesn't establish product facts; retain sample-content labels and don't invent product capabilities or release histories.
|
|
9
|
+
|
|
10
|
+
## Establish the brand
|
|
11
|
+
|
|
12
|
+
Use the supplied website, assets, or brand guide as evidence. Inspect the rendered reference when browser access is available; stylesheets and font declarations can identify exact values. Record the relevant source URLs or file paths with the project, along with colors, font roles and weights, logo variants, spacing, shapes, and intended color modes. Distinguish observed values from approximations. If the reference is unavailable, work from supplied evidence and identify what couldn't be verified.
|
|
13
|
+
|
|
14
|
+
Keep brand-specific values and assets in the customer project. Use available logo and font files with suitable usage rights; preserve notices. Don't copy a reference site's tracking scripts or application bundle into the documentation site.
|
|
15
|
+
|
|
16
|
+
Before editing an existing project, capture a baseline with `shoalrun docs baseline --config docs.config.yaml --out .shoalrun/brand-before.json`. For a customer edition, use the same explicit `--profile FILE` throughout baseline, validation, build, and preview. Read the installed edition guidance before changing profile-owned branding.
|
|
17
|
+
|
|
18
|
+
## Apply supported configuration first
|
|
19
|
+
|
|
20
|
+
Use the installed CLI's bundled references before choosing unfamiliar keys:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
shoalrun docs read reference.config-site
|
|
24
|
+
shoalrun docs read guide.themes
|
|
25
|
+
shoalrun docs read guide.palettes
|
|
26
|
+
shoalrun docs read guide.css
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Set site identity, logo, favicon, and header links through supported `site` settings. Choose the closest `theme.preset`; use `theme.palette` for checked color pairs, `theme.mode` for the requested color behavior, and `theme.fonts` for local body, heading, and code fonts. Declare static font weights accurately and retain fallback families. Keep source and asset paths relative to the configuration that declares them.
|
|
30
|
+
|
|
31
|
+
Use `site.customCss` for remaining visual changes. Prefer documented tokens for broad adjustments and verified generated selectors for specific regions. Project CSS loads after generated styles. Relative CSS images and imports aren't copied automatically; use the documented asset mechanism or site-root URLs for assets that are actually served.
|
|
32
|
+
|
|
33
|
+
## Keep each surface readable
|
|
34
|
+
|
|
35
|
+
A dark header can contain light inputs and result panels. Scope foreground and background tokens together for each surface; don't let header text colors leak into inputs, option lists, buttons, or search results. Inspect actual markup and computed styles before choosing selectors. Search selection can be expressed on a result's parent element rather than its link.
|
|
36
|
+
|
|
37
|
+
Check these states where present:
|
|
38
|
+
|
|
39
|
+
- Header links and buttons: normal, hover, focus, and active.
|
|
40
|
+
- Search: typed text, placeholder, shortcut key, results, summaries, matched terms, empty state, hover, and keyboard selection.
|
|
41
|
+
- Publication and product selectors: label, selected value, open options, and focus ring.
|
|
42
|
+
- Navigation, cards, callouts, tables, and code: text, fills, borders, and links.
|
|
43
|
+
|
|
44
|
+
Check every requested color mode. Normal text needs at least 4.5:1 contrast; large text needs 3:1. Required control boundaries and state indicators need 3:1 against adjacent colors. Keep keyboard focus visible. The compiler checks configured palette pairs, but custom CSS and inherited interaction states need separate verification.
|
|
45
|
+
|
|
46
|
+
## Validate and preview
|
|
47
|
+
|
|
48
|
+
Validate against the baseline with `shoalrun docs validate --baseline .shoalrun/brand-before.json --config docs.config.yaml`, including the selected profile when applicable. Read the report's findings and coverage. Reuse its captured output with `shoalrun docs preview REPORT --port 0`, or follow the project's required build workflow and serve that output. Rebuild when styles, configuration, or assets change.
|
|
49
|
+
|
|
50
|
+
Inspect a landing page and a representative article at desktop and mobile widths. Check font and image loading, overflow, keyboard navigation, dropdown destinations, and search interactions. Confirm HTML navigation and Markdown twins retain the documentation's meaning. A successful build doesn't establish visual correctness; state any browser checks that couldn't run.
|
|
51
|
+
|
|
52
|
+
Present the preview URL, changed files, verified states, and unresolved approximations. Keep the review concise and link to detailed evidence when needed. Follow the user's authorization for commits and publication.
|