diff --git a/.gitignore b/.gitignore index 6283920..6586d4e 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ next-env.d.ts /public/llms-full.txt /public/sitemap.xml /public/robots.txt +/public/ai-suggestions.json # misc .DS_Store diff --git a/app/[[...slug]]/page.tsx b/app/[[...slug]]/page.tsx index 08379fa..518ae8c 100644 --- a/app/[[...slug]]/page.tsx +++ b/app/[[...slug]]/page.tsx @@ -96,7 +96,7 @@ export default async function DocsCatchAll({ params }: { params: Promise<{ slug? // SiteNav. Only docs routes mount it — the home + API reference render their // own full-width content. return ( - + * + * { margin-top: 8px; } .ml-apiref .callout strong { color: var(--ink); } -.ml-apiref .callout.tone-warn { border-left-color: var(--warn); background: color-mix(in srgb, var(--warn) 6%, var(--panel)); } +.ml-apiref .callout.tone-warn { background: color-mix(in srgb, var(--warn) 6%, var(--panel)); } .ml-apiref .callout.tone-warn .ic { color: var(--warn); } -.ml-apiref .callout.tone-ok { border-left-color: var(--ok); background: color-mix(in srgb, var(--ok) 6%, var(--panel)); } +.ml-apiref .callout.tone-ok { background: color-mix(in srgb, var(--ok) 6%, var(--panel)); } .ml-apiref .callout.tone-ok .ic { color: var(--ok); } /* ---------- dark code rail ---------- */ diff --git a/app/docs.css b/app/docs.css index 922af94..4b1c1bc 100644 --- a/app/docs.css +++ b/app/docs.css @@ -98,7 +98,6 @@ font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.13em; text-transform: uppercase; color: var(--ink-4); padding: 0 9px; margin-bottom: 8px; display: flex; align-items: center; gap: 7px; } -.docs-shell .docs-nav-grp > .t .n { color: var(--ink-4); opacity: .6; } .docs-shell .docs-nav a { display: flex; align-items: center; gap: 8px; padding: 6.5px 9px; border-radius: 7px; font-size: 13.5px; color: var(--ink-3); line-height: 1.3; position: relative; text-decoration: none; @@ -190,7 +189,7 @@ /* callout */ .docs-shell .callout { - margin-top: 20px; border: 1px solid var(--line); border-left: 2px solid var(--accent); + margin-top: 20px; border: 1px solid var(--line); border-radius: 9px; padding: 14px 16px; background: color-mix(in srgb, var(--accent) 5%, var(--panel)); display: flex; gap: 12px; max-width: 68ch; @@ -201,9 +200,9 @@ .docs-shell .callout p, .docs-shell .callout .callout-body { margin: 0; font-size: 14px; color: var(--ink-2); line-height: 1.6; } .docs-shell .callout .callout-body > * + * { margin-top: 8px; } .docs-shell .callout strong { color: var(--ink); } -.docs-shell .callout.tone-warn { border-left-color: var(--warn); background: color-mix(in srgb, var(--warn) 6%, var(--panel)); } +.docs-shell .callout.tone-warn { background: color-mix(in srgb, var(--warn) 6%, var(--panel)); } .docs-shell .callout.tone-warn .ic { color: var(--warn); } -.docs-shell .callout.tone-ok { border-left-color: var(--ok); background: color-mix(in srgb, var(--ok) 6%, var(--panel)); } +.docs-shell .callout.tone-ok { background: color-mix(in srgb, var(--ok) 6%, var(--panel)); } .docs-shell .callout.tone-ok .ic { color: var(--ok); } /* steps */ diff --git a/components/docs/ai/ask-dock.tsx b/components/docs/ai/ask-dock.tsx index 2f8b5f8..76be103 100644 --- a/components/docs/ai/ask-dock.tsx +++ b/components/docs/ai/ask-dock.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useRef, useState } from "react"; +import { usePathname } from "next/navigation"; import type { AiPublicConfig } from "@/lib/config"; /** @@ -46,8 +47,22 @@ const CHATS_KEY = "markline-ai-chats"; const OPEN_KEY = "markline-ai-open"; const RKEY_KEY = "markline-ai-key"; +/** Last-resort starter questions — page-agnostic so they're never wrong. */ const SUGGEST = ["Summarize this page", "Explain this with an example", "What are the key concepts here?"]; +/** Build-time generated per-page starter questions (public/ai-suggestions.json, + * written by scripts/build-search.mjs when `ai.suggestions` is enabled). + * Fetched once per session; missing file → empty map (fallbacks apply). */ +let suggestionsPromise: Promise> | null = null; +function loadGeneratedSuggestions(basePath: string): Promise> { + if (!suggestionsPromise) { + suggestionsPromise = fetch(`${basePath}/ai-suggestions.json`) + .then((r) => (r.ok ? r.json() : {})) + .catch(() => ({})); + } + return suggestionsPromise; +} + /** Condense the first question into a clean chat title: strip filler, capitalize, * cut on a word boundary (never mid-word), drop trailing punctuation. Purely * deterministic — no model call, so it works even when the assistant is down. */ @@ -106,7 +121,15 @@ function mdBlocks(s: string): string { return html; } -export function AskDock({ ai }: { ai: AiPublicConfig }) { +export function AskDock({ + ai, + suggestions, +}: { + ai: AiPublicConfig; + /** Page-specific starter questions. Resolution: this prop (frontmatter / + * surface defaults) → build-time generated (ai-suggestions.json) → generic. */ + suggestions?: string[]; +}) { const [chats, setChats] = useState([{ title: "New chat", messages: [] }]); const [cur, setCur] = useState(0); const [open, setOpen] = useState(false); @@ -117,12 +140,32 @@ export function AskDock({ ai }: { ai: AiPublicConfig }) { const [needKey, setNeedKey] = useState(false); const [keyDraft, setKeyDraft] = useState(""); const [attachments, setAttachments] = useState([]); + const [generated, setGenerated] = useState(null); const bodyRef = useRef(null); const inputRef = useRef(null); const fileRef = useRef(null); + const pathname = usePathname(); const messages = chats[cur]?.messages ?? []; + /* per-page starter questions from the build-time generator (when present); + skipped entirely when the page supplies its own via the prop. */ + useEffect(() => { + if (suggestions?.length) return; + let on = true; + const basePath = process.env.NEXT_PUBLIC_MARKLINE_BASE_PATH || ""; + const key = + (basePath && pathname.startsWith(basePath) ? pathname.slice(basePath.length) : pathname).replace(/\/+$/, "") || "/"; + loadGeneratedSuggestions(basePath).then((map) => { + if (on) setGenerated(map[key]?.q?.length ? map[key].q.slice(0, 3) : null); + }); + return () => { + on = false; + }; + }, [pathname, suggestions]); + + const starters = suggestions?.length ? suggestions.slice(0, 3) : generated ?? SUGGEST; + /* hydrate from localStorage + restore open state */ useEffect(() => { try { @@ -387,7 +430,7 @@ export function AskDock({ ai }: { ai: AiPublicConfig }) { Tip: start a new chat with E
- {SUGGEST.map((s) => ( + {starters.map((s) => ( @@ -548,7 +591,7 @@ function Sources({ list }: { list: string[] }) { {list.map((s) => (
- + {s}
@@ -596,3 +639,22 @@ function Book({ sm }: { sm?: boolean }) { ); } +/** Document/page glyph for retrieved sources (a file with a folded corner). */ +function PageIcon() { + return ( + + + + + + ); +} diff --git a/components/docs/api/reference/markline-apiref.tsx b/components/docs/api/reference/markline-apiref.tsx index a52df86..a55614e 100644 --- a/components/docs/api/reference/markline-apiref.tsx +++ b/components/docs/api/reference/markline-apiref.tsx @@ -463,7 +463,16 @@ export function MarklineApiRef({ - {ai && } + {ai && ( + + )}
); diff --git a/components/docs/nav.tsx b/components/docs/nav.tsx index 08ff34d..6e40fab 100644 --- a/components/docs/nav.tsx +++ b/components/docs/nav.tsx @@ -41,8 +41,8 @@ function pickActiveTabId(tabs: TopTab[], pathname: string): string { return tabs.find((t) => t.matchPrefixes.includes("__default__"))?.id ?? tabs[0]?.id ?? ""; } -/** Numbered nav groups (the design's .docs-nav): "01 Get started", etc. The - * index is derived from the section's position. Method/badge chips are kept. */ +/** Nav groups (the design's .docs-nav): a section title over its links. + * Method/badge chips on links are kept. */ function SidebarSections({ sections, pathname, @@ -56,9 +56,7 @@ function SidebarSections({