@elcoosp/docs-toolkit 0.0.0-stage → 0.5.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.
Files changed (66) hide show
  1. package/README.md +17 -2
  2. package/dist/accent-efJW3hXp.mjs +512 -0
  3. package/dist/accent-efJW3hXp.mjs.map +1 -0
  4. package/dist/assets/font-b64.ts +3 -0
  5. package/dist/assets/roboto-latin-400-normal.woff2 +0 -0
  6. package/dist/config/changelog/rss.d.mts +3 -0
  7. package/dist/config/changelog/rss.mjs +59 -0
  8. package/dist/config/changelog/rss.mjs.map +1 -0
  9. package/dist/config/index.d.mts +2 -0
  10. package/dist/config/index.mjs +1 -0
  11. package/dist/config-4H2Ezkkg.mjs +403 -0
  12. package/dist/config-4H2Ezkkg.mjs.map +1 -0
  13. package/dist/index-CMRfP1d6.d.mts +78 -0
  14. package/dist/index-v636y-WJ.d.mts +40 -0
  15. package/dist/index.d.mts +4 -0
  16. package/dist/index.mjs +5 -0
  17. package/dist/og/index.d.mts +3 -0
  18. package/dist/og/index.mjs +4 -0
  19. package/dist/og/routeData.d.mts +2 -0
  20. package/dist/og/routeData.mjs +1 -0
  21. package/dist/og-BS4p2VWc.mjs +3 -0
  22. package/dist/qa/bootstrap-artifacts.d.mts +1 -0
  23. package/dist/qa/bootstrap-artifacts.mjs +49 -0
  24. package/dist/qa/bootstrap-artifacts.mjs.map +1 -0
  25. package/dist/qa/components.d.mts +1 -0
  26. package/dist/qa/components.mjs +42 -0
  27. package/dist/qa/components.mjs.map +1 -0
  28. package/dist/qa/screenshots.d.mts +1 -0
  29. package/dist/qa/screenshots.mjs +145 -0
  30. package/dist/qa/screenshots.mjs.map +1 -0
  31. package/dist/routeData-BWKOWSqF.mjs +101 -0
  32. package/dist/routeData-BWKOWSqF.mjs.map +1 -0
  33. package/dist/routeData-CqCdkH0K.d.mts +27 -0
  34. package/package.json +86 -4
  35. package/src/assets/font-b64.ts +3 -0
  36. package/src/assets/roboto-latin-400-normal.woff2 +0 -0
  37. package/src/config/changelog/integration.ts +69 -0
  38. package/src/config/changelog/page.astro +33 -0
  39. package/src/config/changelog/page.ts +46 -0
  40. package/src/config/changelog/parse.ts +38 -0
  41. package/src/config/changelog/rss.ts +23 -0
  42. package/src/config/changelog/types.ts +18 -0
  43. package/src/config/components/PageActions.astro +124 -0
  44. package/src/config/index.ts +183 -0
  45. package/src/config/llms/collect.ts +66 -0
  46. package/src/config/llms/config.ts +24 -0
  47. package/src/config/llms/emit.ts +56 -0
  48. package/src/config/llms/head.ts +6 -0
  49. package/src/config/llms/index.ts +5 -0
  50. package/src/config/llms/integration.ts +26 -0
  51. package/src/config/llms/render/full.ts +11 -0
  52. package/src/config/llms/render/index.ts +25 -0
  53. package/src/config/llms/render/json.ts +21 -0
  54. package/src/config/llms/render/page.ts +8 -0
  55. package/src/config/llms/render/small.ts +8 -0
  56. package/src/config/llms/sections.ts +34 -0
  57. package/src/config/llms/types.ts +34 -0
  58. package/src/index.ts +3 -0
  59. package/src/og/accent.ts +143 -0
  60. package/src/og/index.ts +5 -0
  61. package/src/og/jsx.d.ts +11 -0
  62. package/src/og/og-render.tsx +155 -0
  63. package/src/og/routeData.ts +90 -0
  64. package/src/qa/bootstrap-artifacts.ts +36 -0
  65. package/src/qa/components.ts +23 -0
  66. package/src/qa/screenshots.ts +197 -0
@@ -0,0 +1,183 @@
1
+ import fs from 'node:fs';
2
+ import { ROBOTO_BASE64 } from '../assets/font-b64';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { defineConfig } from 'astro/config';
5
+ import type { AstroUserConfig } from 'astro';
6
+ import starlight from '@astrojs/starlight';
7
+ import type { StarlightUserConfig } from '@astrojs/starlight/types';
8
+ import astroTakumi from 'astro-takumi';
9
+ import { makeRender } from '../og/og-render';
10
+ import { deriveAccent } from '../og/accent';
11
+ import { resolveLlmsConfig, llmsIntegration, llmsHeadLink } from './llms';
12
+ import type { LlmsTxtConfig } from './llms';
13
+ import { changelogIntegration } from './changelog/integration';
14
+ import type { ChangelogConfig } from './changelog/types';
15
+
16
+ import starlightTelescope from 'starlight-telescope';
17
+ import starlightFullviewMode from 'starlight-fullview-mode';
18
+ import starlightTags from 'starlight-tags';
19
+ import starlightScrollToTop from 'starlight-scroll-to-top';
20
+ import starlightAnnouncement from 'starlight-announcement';
21
+
22
+ import starlightSeo from 'starlight-seo';
23
+ import starlightBlog from 'starlight-blog';
24
+ import starlightGiscus from 'starlight-giscus';
25
+ import mermaid from 'astro-mermaid';
26
+
27
+
28
+
29
+ export type { LlmsTxtConfig } from './llms';
30
+ export type { ChangelogConfig, Release } from './changelog/types';
31
+ export { DEFAULT_SECTIONS as DEFAULT_LLMS_SETS, detectSections } from './llms';
32
+
33
+ export interface DocsConfigOptions {
34
+ site: string;
35
+ domain: string;
36
+ title: string;
37
+ projectName?: string;
38
+ description: string;
39
+ logoPath?: string;
40
+ favicon?: string;
41
+ customCss?: string[];
42
+ social?: StarlightUserConfig['social'];
43
+ editLink?: StarlightUserConfig['editLink'];
44
+ locales?: StarlightUserConfig['locales'];
45
+ defaultLocale?: StarlightUserConfig['defaultLocale'];
46
+ plugins?: StarlightUserConfig['plugins'];
47
+ sidebar: StarlightUserConfig['sidebar'];
48
+ routeMiddleware?: string;
49
+ base?: string;
50
+ accentOverride?: string;
51
+ expressiveCode?: StarlightUserConfig['expressiveCode'];
52
+ extraStarlight?: Partial<StarlightUserConfig>;
53
+ extraIntegrations?: NonNullable<AstroUserConfig['integrations']>;
54
+ extraVite?: Record<string, unknown>;
55
+ /** Configure starlight-llms-txt. `false` disables the plugin entirely. */
56
+ llmsTxt?: LlmsTxtConfig | false;
57
+ giscus?: any;
58
+ changelog?: ChangelogConfig;
59
+ }
60
+
61
+ export async function defineDocsConfig(
62
+ options: DocsConfigOptions,
63
+ ): Promise<AstroUserConfig> {
64
+ const {
65
+ site,
66
+ domain,
67
+ title,
68
+ projectName = title,
69
+ description,
70
+ logoPath = './src/assets/logo.png',
71
+ favicon = '/favicon.png',
72
+ customCss = ['./src/styles/custom.css'],
73
+ social = [],
74
+ editLink,
75
+ locales,
76
+ defaultLocale = 'root',
77
+ plugins = [],
78
+ sidebar,
79
+ routeMiddleware = './src/routeData.ts',
80
+ base,
81
+ accentOverride,
82
+ expressiveCode = { themes: ['github-light', 'github-dark'] },
83
+ extraStarlight = {},
84
+ extraIntegrations = [],
85
+ extraVite = {},
86
+ llmsTxt = {},
87
+ changelog,
88
+ } = options;
89
+
90
+ const accent = accentOverride
91
+ ? { hex: accentOverride, hexA12: accentOverride + '1a' }
92
+ : await deriveAccent(logoPath);
93
+
94
+ const logoB64 = fs.readFileSync(logoPath).toString('base64');
95
+ const render = makeRender({
96
+ logoDataUrl: 'data:image/png;base64,' + logoB64,
97
+ accentHex: accent.hex,
98
+ accentHexA12: accent.hexA12,
99
+ domain,
100
+ projectName,
101
+ });
102
+
103
+ const fontBuf = Buffer.from(ROBOTO_BASE64, 'base64');
104
+
105
+ const starlightPlugins = [...plugins];
106
+
107
+
108
+ const sidebarWithChangelog = changelog
109
+ ? [...sidebar, { label: changelog.sidebarLabel ?? 'Changelog', link: changelog.pagePath ?? '/changelog/' }]
110
+ : sidebar;
111
+
112
+ let headLink: any = undefined;
113
+ let resolvedLlms: any = undefined;
114
+ if (llmsTxt !== false) {
115
+ resolvedLlms = resolveLlmsConfig(llmsTxt ?? {}, { title, description, site, source: undefined });
116
+ if (resolvedLlms.outputs.headLink) headLink = llmsHeadLink();
117
+ }
118
+
119
+ const starlightOptions: StarlightUserConfig = {
120
+ components: {
121
+ PageTitle: '@elcoosp/docs-toolkit/PageActions.astro',
122
+ },
123
+ routeMiddleware,
124
+ expressiveCode,
125
+ title,
126
+ description,
127
+ logo: { src: logoPath },
128
+ favicon,
129
+ customCss,
130
+ social,
131
+ ...(editLink ? { editLink } : {}),
132
+ ...(locales ? { locales } : {}),
133
+ ...(defaultLocale ? { defaultLocale } : {}),
134
+ plugins: starlightPlugins,
135
+ ...(changelog ? { head: [{ tag: 'link', attrs: { rel: 'alternate', type: 'application/rss+xml', title: changelog.pageTitle ?? `${title} changelog`, href: changelog.rssPath ?? '/changelog.xml' } }] } : {}),
136
+ head: [...((extraStarlight as any)?.head ?? []), ...(headLink ? [headLink] : [])],
137
+ sidebar: sidebarWithChangelog,
138
+ ...extraStarlight,
139
+ };
140
+
141
+ const integrations: NonNullable<AstroUserConfig['integrations']> = [
142
+ mermaid(), // MUST be before starlight
143
+ starlight(starlightOptions),
144
+ ...extraIntegrations,
145
+ astroTakumi({
146
+ options: {
147
+ domBackend: 'linkedom',
148
+ fonts: [fontBuf],
149
+ width: 1200,
150
+ height: 630,
151
+ format: 'png',
152
+ },
153
+ render,
154
+ } as unknown as Parameters<typeof astroTakumi>[0]),
155
+ ];
156
+
157
+ if (llmsTxt !== false && resolvedLlms) {
158
+ integrations.push(llmsIntegration(resolvedLlms));
159
+ }
160
+
161
+ if (changelog) {
162
+ integrations.push(
163
+ changelogIntegration({
164
+ repo: changelog.repo,
165
+ changelogPath: changelog.changelogPath ?? '../CHANGELOG.md',
166
+ pagePath: changelog.pagePath ?? '/changelog',
167
+ rssPath: changelog.rssPath ?? '/changelog.xml',
168
+ sidebarLabel: changelog.sidebarLabel ?? 'Changelog',
169
+ pageTitle: changelog.pageTitle ?? `${title} changelog`,
170
+ description: changelog.description ?? description,
171
+ disableRss: changelog.disableRss ?? false,
172
+ disablePage: changelog.disablePage ?? false,
173
+ }),
174
+ );
175
+ }
176
+
177
+ return defineConfig({
178
+ vite: { ssr: { external: ['jsdom', 'astro-takumi'], noExternal: ['starlight-llms-txt'] }, ...extraVite },
179
+ ...(site ? { site } : {}),
180
+ ...(base ? { base } : {}),
181
+ integrations,
182
+ });
183
+ }
@@ -0,0 +1,66 @@
1
+ import { readdirSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { PageEntry } from './types.js';
4
+
5
+ const TITLE_RE = /^title:\s*(?:["'](.+?)["']|(.+?))\s*$/m;
6
+ const DESC_RE = /^description:\s*(?:["'](.+?)["']|(.+?))\s*$/m;
7
+ const TYPE_RE = /^type:\s*(?:["'](.+?)["']|(.+?))\s*$/m;
8
+ const ORDER_RE = /^order:\s*(\d+)/;
9
+
10
+ function stripFrontmatter(text: string) {
11
+ const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
12
+ if (!m) return { fm: '', body: text };
13
+ return { fm: m[1], body: text.slice(m[0].length) };
14
+ }
15
+
16
+ function cleanBody(body: string) {
17
+ return body
18
+ .replace(/^import\s+.*?;?\s*$/gm, '')
19
+ .replace(/<\/?(Card|CardGrid|LinkCard|Tabs|TabItem|Steps|FileTree|Aside|Badge)[^>]*>/g, '')
20
+ .replace(/\n{3,}/g, '\n\n')
21
+ .trim();
22
+ }
23
+
24
+ export function collectPages(contentRoot: string): PageEntry[] {
25
+ const pages: PageEntry[] = [];
26
+ function walk(dir: string, prefix: string) {
27
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
28
+ if (entry.name.startsWith('.')) continue;
29
+ const full = path.join(dir, entry.name);
30
+ if (entry.isDirectory()) {
31
+ walk(full, prefix + entry.name + '/');
32
+ } else if (/\.(md|mdx)$/.test(entry.name)) {
33
+ const rel = prefix + entry.name;
34
+ const raw = readFileSync(full, 'utf8');
35
+ const { fm, body } = stripFrontmatter(raw);
36
+
37
+ const titleMatch = fm.match(TITLE_RE);
38
+ const title = (titleMatch?.[1] || titleMatch?.[2] || entry.name.replace(/\.(md|mdx)$/, '')).trim();
39
+
40
+ const descMatch = fm.match(DESC_RE);
41
+ const description = (descMatch?.[1] || descMatch?.[2] || '').trim();
42
+
43
+ const typeMatch = fm.match(TYPE_RE);
44
+ const type = (typeMatch?.[1] || typeMatch?.[2] || '').trim();
45
+
46
+ const orderMatch = fm.match(ORDER_RE);
47
+ const order = orderMatch ? Number(orderMatch[1]) : 99;
48
+
49
+ const noExt = rel.replace(/\.(md|mdx)$/, '');
50
+ const slug = noExt === 'index' ? 'index' : noExt;
51
+ const route = slug === 'index' ? '/' : `/${slug}/`;
52
+ const markdownRoute = slug === 'index' ? '/index.md' : `/${slug}.md`;
53
+ const section = slug === 'index' ? 'Overview' : humanizeDir(slug.split('/')[0]);
54
+
55
+ pages.push({ slug, route, markdownRoute, title, description, type, order, section, body: cleanBody(body) });
56
+ }
57
+ }
58
+ }
59
+
60
+ function humanizeDir(d: string) {
61
+ return d.split(/[-_]/).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
62
+ }
63
+
64
+ walk(contentRoot, '');
65
+ return pages.sort((a, b) => a.order - b.order || a.title.localeCompare(b.title));
66
+ }
@@ -0,0 +1,24 @@
1
+ import { LlmsTxtConfig, ResolvedLlmsConfig } from './types.js';
2
+ import { detectSections, DEFAULT_SECTIONS } from './sections.js';
3
+
4
+ export function resolveLlmsConfig(input: LlmsTxtConfig, ctx: { title: string, description: string, site?: string, source?: string }): ResolvedLlmsConfig {
5
+ const sets = input.sets || detectSections() || DEFAULT_SECTIONS;
6
+ return {
7
+ title: input.title ?? ctx.title,
8
+ description: input.description ?? ctx.description,
9
+ url: input.url ?? ctx.site ?? '',
10
+ source: input.source ?? ctx.source ?? '',
11
+ sets,
12
+ exclude: input.exclude ?? [],
13
+ optional: input.optional ?? [],
14
+ outputs: {
15
+ index: input.outputs?.index ?? true,
16
+ full: input.outputs?.full ?? true,
17
+ small: input.outputs?.small ?? true,
18
+ json: input.outputs?.json ?? true, // Default TRUE for state-of-the-art
19
+ pages: input.outputs?.pages ?? true, // Default TRUE for state-of-the-art
20
+ robots: input.outputs?.robots ?? true,
21
+ headLink: input.outputs?.headLink ?? true,
22
+ }
23
+ };
24
+ }
@@ -0,0 +1,56 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { PageEntry, ResolvedLlmsConfig } from './types.js';
4
+ import { renderIndex } from './render/index.js';
5
+ import { renderFull } from './render/full.js';
6
+ import { renderSmall } from './render/small.js';
7
+ import { renderJson } from './render/json.js';
8
+ import { renderPageMd } from './render/page.js';
9
+
10
+ export function emitAll(
11
+ distDir: string,
12
+ pages: PageEntry[],
13
+ cfg: ResolvedLlmsConfig,
14
+ log: (msg: string) => void = console.log,
15
+ ): void {
16
+ const safeWrite = (file: string, fn: () => string) => {
17
+ try {
18
+ const content = fn();
19
+ const p = path.join(distDir, file);
20
+ fs.mkdirSync(path.dirname(p), { recursive: true });
21
+ fs.writeFileSync(p, content);
22
+ log(` ✓ ${file} (${content.length} bytes)`);
23
+ } catch (e) {
24
+ log(` ✗ ${file} — ${e instanceof Error ? e.message : String(e)}`);
25
+ }
26
+ };
27
+
28
+ if (cfg.outputs.index) safeWrite('llms.txt', () => renderIndex(pages, cfg));
29
+ if (cfg.outputs.full) safeWrite('llms-full.txt', () => renderFull(pages, cfg));
30
+ if (cfg.outputs.small) safeWrite('llms-small.txt', () => renderSmall(pages, cfg));
31
+ if (cfg.outputs.json) safeWrite('llms.json', () => renderJson(pages, cfg));
32
+
33
+ if (cfg.outputs.pages) {
34
+ for (const p of pages) {
35
+ const target = p.slug === 'index' ? 'index.md' : `${p.slug}.md`;
36
+ safeWrite(target, () => renderPageMd(p));
37
+ }
38
+ }
39
+
40
+ if (cfg.outputs.robots) {
41
+ try {
42
+ const robotsPath = path.join(distDir, 'robots.txt');
43
+ const line = 'LLM: /llms.txt\n';
44
+ if (fs.existsSync(robotsPath)) {
45
+ const content = fs.readFileSync(robotsPath, 'utf8');
46
+ if (!content.includes('LLM:')) fs.appendFileSync(robotsPath, `\n${line}`);
47
+ log(' ✓ robots.txt (appended LLM: /llms.txt)');
48
+ } else {
49
+ fs.writeFileSync(robotsPath, line);
50
+ log(' ✓ robots.txt (created)');
51
+ }
52
+ } catch (e) {
53
+ log(` ✗ robots.txt — ${e instanceof Error ? e.message : String(e)}`);
54
+ }
55
+ }
56
+ }
@@ -0,0 +1,6 @@
1
+ export function llmsHeadLink() {
2
+ return {
3
+ tag: 'link',
4
+ attrs: { rel: 'llms-txt', href: '/llms.txt' }
5
+ };
6
+ }
@@ -0,0 +1,5 @@
1
+ export * from './types.js';
2
+ export * from './sections.js';
3
+ export * from './config.js';
4
+ export * from './integration.js';
5
+ export * from './head.js';
@@ -0,0 +1,26 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import path from 'node:path';
3
+ import type { AstroIntegration } from 'astro';
4
+ import { ResolvedLlmsConfig } from './types.js';
5
+ import { collectPages } from './collect.js';
6
+ import { emitAll } from './emit.js';
7
+
8
+ export function llmsIntegration(cfg: ResolvedLlmsConfig): AstroIntegration {
9
+ return {
10
+ name: '@elcoosp/docs-toolkit/llms',
11
+ hooks: {
12
+ 'astro:build:done': async ({ dir, logger }) => {
13
+ const distDir = fileURLToPath(dir);
14
+ const contentRoot = path.resolve(process.cwd(), 'src/content/docs');
15
+ logger.info('llms: collecting pages...');
16
+ try {
17
+ const pages = collectPages(contentRoot);
18
+ logger.info(`llms: ${pages.length} pages collected`);
19
+ emitAll(distDir, pages, cfg, (msg) => logger.info(`llms:${msg}`));
20
+ } catch (e) {
21
+ logger.error(`llms: FAILED — ${e instanceof Error ? e.stack : String(e)}`);
22
+ }
23
+ }
24
+ }
25
+ };
26
+ }
@@ -0,0 +1,11 @@
1
+ import { PageEntry, ResolvedLlmsConfig } from '../types.js';
2
+
3
+ export function renderFull(pages: PageEntry[], cfg: ResolvedLlmsConfig): string {
4
+ let out = `# ${cfg.title} — full documentation\n\n> ${cfg.description}\n\nSource: ${cfg.source}\n\n`;
5
+ for (const p of pages) {
6
+ out += `---\n\n# ${p.title}\n\n`;
7
+ if (p.description) out += `${p.description}\n\n`;
8
+ out += `${p.body}\n\n`;
9
+ }
10
+ return out.trim() + '\n';
11
+ }
@@ -0,0 +1,25 @@
1
+ import { PageEntry, ResolvedLlmsConfig } from '../types.js';
2
+
3
+ export function renderIndex(pages: PageEntry[], cfg: ResolvedLlmsConfig): string {
4
+ let out = `# ${cfg.title}\n\n> ${cfg.description}\n\nSource: ${cfg.source}\n`;
5
+
6
+ const sections = [...new Set(pages.map(p => p.section))].filter(s => s !== 'Overview' && s !== 'Optional');
7
+
8
+ for (const section of sections) {
9
+ const group = pages.filter(p => p.section === section);
10
+ out += `\n## ${section}\n\n`;
11
+ for (const p of group) {
12
+ out += `- [${p.title}](${cfg.url}${p.route}): ${p.description}\n`;
13
+ }
14
+ }
15
+
16
+ const optional = pages.filter(p => p.section === 'Optional' || cfg.optional.includes(p.slug));
17
+ if (optional.length > 0) {
18
+ out += `\n## Optional\n\n`;
19
+ for (const p of optional) {
20
+ out += `- [${p.title}](${cfg.url}${p.route}): ${p.description}\n`;
21
+ }
22
+ }
23
+
24
+ return out.trim() + '\n';
25
+ }
@@ -0,0 +1,21 @@
1
+ import { PageEntry, ResolvedLlmsConfig } from '../types.js';
2
+
3
+ export function renderJson(pages: PageEntry[], cfg: ResolvedLlmsConfig): string {
4
+ return JSON.stringify({
5
+ $schema: "https://llmstxt.org/schema.json",
6
+ title: cfg.title,
7
+ description: cfg.description,
8
+ url: cfg.url,
9
+ source: cfg.source,
10
+ pages: pages.map(p => ({
11
+ slug: p.slug,
12
+ route: p.route,
13
+ markdownRoute: p.markdownRoute,
14
+ title: p.title,
15
+ description: p.description,
16
+ type: p.type,
17
+ order: p.order,
18
+ section: p.section
19
+ }))
20
+ }, null, 2);
21
+ }
@@ -0,0 +1,8 @@
1
+ import { PageEntry } from '../types.js';
2
+
3
+ export function renderPageMd(p: PageEntry): string {
4
+ let out = `# ${p.title}\n\n`;
5
+ if (p.description) out += `> ${p.description}\n\n`;
6
+ out += p.body;
7
+ return out.trim() + '\n';
8
+ }
@@ -0,0 +1,8 @@
1
+ import { PageEntry, ResolvedLlmsConfig } from '../types.js';
2
+ import { renderFull } from './full.js';
3
+
4
+ export function renderSmall(pages: PageEntry[], cfg: ResolvedLlmsConfig): string {
5
+ // Curated subset: pages with `order: N` where N < 99, OR type === 'overview'
6
+ const curated = pages.filter(p => p.order < 99 || p.type === 'overview');
7
+ return renderFull(curated, cfg).replace('— full documentation', '— curated subset');
8
+ }
@@ -0,0 +1,34 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export const DEFAULT_SECTIONS = [
5
+ { label: 'Tutorials', paths: ['tutorials/**'] },
6
+ { label: 'How-to Guides', paths: ['guides/**'] },
7
+ { label: 'Reference', paths: ['reference/**'] },
8
+ { label: 'Explanation', paths: ['explanation/**'] },
9
+ ];
10
+
11
+ export function humanize(name: string): string {
12
+ return name.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
13
+ }
14
+
15
+ export function detectSections(contentRoot = 'src/content/docs') {
16
+ try {
17
+ const entries = fs.readdirSync(contentRoot, { withFileTypes: true });
18
+ const sections: Array<{ label: string; paths: string[] }> = [];
19
+ for (const e of entries) {
20
+ if (!e.isDirectory() || e.name.startsWith('.') || e.name === '_') continue;
21
+ const hasContent = (function walk(dir: string): boolean {
22
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
23
+ if (entry.isFile() && /\.(md|mdx)$/.test(entry.name)) return true;
24
+ if (entry.isDirectory() && walk(path.join(dir, entry.name))) return true;
25
+ }
26
+ return false;
27
+ })(path.join(contentRoot, e.name));
28
+ if (hasContent) sections.push({ label: humanize(e.name), paths: [`${e.name}/**`] });
29
+ }
30
+ return sections.length > 0 ? sections : null;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
@@ -0,0 +1,34 @@
1
+ export interface PageEntry {
2
+ slug: string;
3
+ route: string;
4
+ markdownRoute: string;
5
+ title: string;
6
+ description: string;
7
+ type: string;
8
+ order: number;
9
+ section: string;
10
+ body: string;
11
+ }
12
+
13
+ export interface LlmsTxtConfig {
14
+ title?: string;
15
+ description?: string;
16
+ url?: string;
17
+ source?: string;
18
+ sets?: Array<{ label: string; paths: string[]; description?: string }>;
19
+ exclude?: string[];
20
+ optional?: string[];
21
+ outputs?: {
22
+ index?: boolean;
23
+ full?: boolean;
24
+ small?: boolean;
25
+ json?: boolean;
26
+ pages?: boolean;
27
+ robots?: boolean;
28
+ headLink?: boolean;
29
+ };
30
+ }
31
+
32
+ export interface ResolvedLlmsConfig extends Required<Omit<LlmsTxtConfig, 'outputs'>> {
33
+ outputs: Required<NonNullable<LlmsTxtConfig['outputs']>>;
34
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './og';
2
+ export { defineDocsConfig } from './config';
3
+ export type { DocsConfigOptions } from './config';
@@ -0,0 +1,143 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import sharp from 'sharp';
3
+
4
+ const clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, v));
5
+
6
+ interface Rgb { r: number; g: number; b: number }
7
+ interface Hsl { h: number; s: number; l: number }
8
+
9
+ function rgbToHsl(r: number, g: number, b: number): Hsl {
10
+ r /= 255; g /= 255; b /= 255;
11
+ const mx = Math.max(r, g, b);
12
+ const mn = Math.min(r, g, b);
13
+ const l = (mx + mn) / 2;
14
+ let h = 0;
15
+ let s = 0;
16
+ if (mx !== mn) {
17
+ const d = mx - mn;
18
+ s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
19
+ if (mx === r) h = ((g - b) / d + (g < b ? 6 : 0)) * 60;
20
+ else if (mx === g) h = ((b - r) / d + 2) * 60;
21
+ else h = ((r - g) / d + 4) * 60;
22
+ }
23
+ return { h: Math.round(h), s: Math.round(s * 100), l: Math.round(l * 100) };
24
+ }
25
+
26
+ function hslToRgb(h: number, s: number, l: number): [number, number, number] {
27
+ s /= 100; l /= 100;
28
+ const c = (1 - Math.abs(2 * l - 1)) * s;
29
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
30
+ const m = l - c / 2;
31
+ let r = 0, g = 0, b = 0;
32
+ if (h < 60) [r, g, b] = [c, x, 0];
33
+ else if (h < 120) [r, g, b] = [x, c, 0];
34
+ else if (h < 180) [r, g, b] = [0, c, x];
35
+ else if (h < 240) [r, g, b] = [0, x, c];
36
+ else if (h < 300) [r, g, b] = [x, 0, c];
37
+ else [r, g, b] = [c, 0, x];
38
+ return [r, g, b].map((v) => Math.round((v + m) * 255)) as [number, number, number];
39
+ }
40
+
41
+ const rgbToHex = (r: number, g: number, b: number): string =>
42
+ '#' + [r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('');
43
+
44
+ const hslToHex = (h: number, s: number, l: number): string => rgbToHex(...hslToRgb(h, s, l));
45
+
46
+ export type AccentStyle = 'graydark' | 'chroma' | 'fallback' | 'empty';
47
+
48
+ export interface AccentHslBand {
49
+ h: number;
50
+ s: number;
51
+ l: number;
52
+ low: number;
53
+ high: number;
54
+ }
55
+
56
+ export interface AccentResult {
57
+ hex: string;
58
+ hexA12: string;
59
+ style: AccentStyle;
60
+ chroma: number;
61
+ logoHex: string;
62
+ hsl: { light: AccentHslBand; dark: AccentHslBand };
63
+ }
64
+
65
+ export interface DeriveAccentOptions {
66
+ fallbackHex?: string;
67
+ style?: AccentStyle;
68
+ }
69
+
70
+ export async function deriveAccent(
71
+ logoPath: string,
72
+ opts: DeriveAccentOptions = {},
73
+ ): Promise<AccentResult> {
74
+ const { fallbackHex = '#404040', style: forceStyle } = opts;
75
+
76
+ const fallback = (style: AccentStyle): AccentResult => ({
77
+ hex: fallbackHex,
78
+ hexA12: fallbackHex + '1a',
79
+ style,
80
+ chroma: 0,
81
+ logoHex: fallbackHex,
82
+ hsl: {
83
+ light: { h: 0, s: 0, l: 25, low: 85, high: 17 },
84
+ dark: { h: 0, s: 0, l: 75, low: 45, high: 85 },
85
+ },
86
+ });
87
+
88
+ let logoBuf: Buffer;
89
+ try {
90
+ logoBuf = await readFile(logoPath);
91
+ } catch {
92
+ return fallback('fallback');
93
+ }
94
+
95
+ const { data } = await sharp(logoBuf)
96
+ .resize(64, 64, { fit: 'inside' })
97
+ .ensureAlpha()
98
+ .raw()
99
+ .toBuffer({ resolveWithObject: true });
100
+
101
+ const px: Rgb[] = [];
102
+ for (let i = 0; i < data.length; i += 4) {
103
+ const r = data[i]!;
104
+ const g = data[i + 1]!;
105
+ const b = data[i + 2]!;
106
+ const a = data[i + 3]!;
107
+ if (a < 128) continue;
108
+ if (r > 240 && g > 240 && b > 240) continue;
109
+ if (r < 15 && g < 15 && b < 15) continue;
110
+ px.push({ r, g, b });
111
+ }
112
+ if (px.length === 0) return fallback('empty');
113
+
114
+ const med = (['r', 'g', 'b'] as const).map((ch) => {
115
+ const sorted = px.map((x) => x[ch]).sort((a, b) => a - b);
116
+ return sorted[Math.floor(sorted.length / 2)]!;
117
+ }) as [number, number, number];
118
+
119
+ const { h, s, l } = rgbToHsl(...med);
120
+ const chroma = (px.filter((q) => rgbToHsl(q.r, q.g, q.b).s > 15).length / px.length) * 100;
121
+
122
+ const style: AccentStyle = forceStyle ?? (chroma < 20 ? 'graydark' : 'chroma');
123
+
124
+ const lightL =
125
+ style === 'graydark' ? clamp(Math.round(l / 5) * 5, 20, 35) : clamp(Math.round(l / 5) * 5, 30, 40);
126
+ const lightH = style === 'graydark' ? 0 : h;
127
+ const lightS = style === 'graydark' ? 0 : clamp(Math.round(s / 5) * 5, 45, 70);
128
+
129
+ const hex = hslToHex(lightH, lightS, lightL);
130
+ const darkL = 100 - lightL;
131
+
132
+ return {
133
+ hex,
134
+ hexA12: hex + '1a',
135
+ style,
136
+ chroma: Math.round(chroma),
137
+ logoHex: rgbToHex(...med),
138
+ hsl: {
139
+ light: { h: lightH, s: lightS, l: lightL, low: Math.min(lightL + 60, 95), high: Math.max(lightL - 8, 2) },
140
+ dark: { h: lightH, s: lightS, l: darkL, low: lightL + 20, high: Math.min(darkL + 10, 98) },
141
+ },
142
+ };
143
+ }
@@ -0,0 +1,5 @@
1
+ export { makeRender } from './og-render';
2
+ export { deriveAccent } from './accent';
3
+ export type { AccentResult, AccentStyle, AccentHslBand, DeriveAccentOptions } from './accent';
4
+ export { onRequest } from './routeData';
5
+ export type { RouteContext } from './routeData';