diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bea39df33..15fbfc395 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,37 @@ jobs: env: CI: true + # test-docs-server-ssr: + # runs-on: ubuntu-latest + # steps: + # - uses: actions/checkout@v4 + # - uses: pnpm/action-setup@v4.0.0 + # - uses: actions/setup-node@v4 + # with: + # node-version: ${{ env.NODE_VERSION }} + # cache: pnpm + # - run: pnpm install --frozen-lockfile + # - name: Run docs unit tests (server + ssr) + # run: pnpm --filter docs test:unit --project server --project ssr + # env: + # CI: true + + # test-docs-browser: + # runs-on: ubuntu-latest + # steps: + # - uses: actions/checkout@v4 + # - uses: pnpm/action-setup@v4.0.0 + # - uses: actions/setup-node@v4 + # with: + # node-version: ${{ env.NODE_VERSION }} + # cache: pnpm + # - run: pnpm install --frozen-lockfile + # - run: pnpm --filter docs exec playwright install chromium + # - name: Run docs browser tests (client) + # run: pnpm --filter docs test:unit --project client + # env: + # CI: true + build: runs-on: ubuntu-latest steps: diff --git a/docs/content-collections.ts b/docs/content-collections.ts index 93a957315..91516bfc8 100644 --- a/docs/content-collections.ts +++ b/docs/content-collections.ts @@ -155,6 +155,26 @@ const utils = defineCollection({ } }); +const guides = defineCollection({ + name: 'guides', + directory: 'src/content/guides', + include: '**/*.md', + schema: z.object({ + title: z.string(), + description: z.string().optional(), + order: z.number().optional(), + draft: z.boolean().default(false) + }), + transform: async (doc) => { + const { path } = doc._meta; + return { + ...doc, + name: doc.title, + slug: path + }; + } +}); + const releases = defineCollection({ name: 'releases', directory: 'src/content/releases', @@ -181,5 +201,5 @@ const releases = defineCollection({ }); export default defineConfig({ - collections: [components, examples, utils, releases] + collections: [components, examples, utils, guides, releases] }); diff --git a/docs/src/content/guides/LLMs.md b/docs/src/content/guides/LLMs.md new file mode 100644 index 000000000..a6ad23d23 --- /dev/null +++ b/docs/src/content/guides/LLMs.md @@ -0,0 +1,52 @@ +--- +title: LLMs +order: 8 +--- + + + +The LayerChart documentation pages are designed to be accessible for humans developers using LLMs as well as large language models (LLMs) ingesting training data. + +## :icon{name="lucide:user" class="relative -top-1"} For the Humans + + + +At the top of each documentation page, and demonstrated above, you will find a button which copies the content of the page's documentation in Markdown to the clipboard. The convenient dropdown also gives you additional helpful options, such as viewing the component source or opening in chat. + +::note +The option for `View Component Source` is only shown for component pages. +:: + +## :icon{name="lucide:bot" class="relative -top-1"} For the Bots + +LayerChart adopts the [llms.txt](https://llmstxt.org/) proposal standard, which provides a structured, machine-readable format optimized for LLMs. This enables developers, researchers, and AI systems to efficiently parse and utilize our documentation. + +::note +Most but not all pages support the `/llms.txt` suffix (i.e. those deemed irrelevant to LLMs). +:: + +## LLM-friendly Documentation 3 Ways + +::steps + +## Per page / component + +To access the LLM-friendly version of supported documentation pages, simply append `/llms.txt` to the end of the page's URL. This will return the content in a plain-text, LLM-optimized format. This is the same text which is copied to the clipboard when you click the `Copy Page` button. + +:::tip +**Standard Page**: The LineChart component documentation is available at [/docs/components/LineChart](/docs/components/LineChart) + +**LLM-friendly Version**: is available at [/docs/components/LineChart/llms.txt](/docs/components/LineChart/llms.txt) +::: + +## Root Index + +To explore all supported pages in LLM-friendly format, visit the root index at [llms.txt](/llms.txt). This page provides a comprehensive list of available documentation endpoints compatible with the `llms.txt` standard. + +## Complete Documentation + +For a complete, consolidated view of the all the documentation in an LLM-friendly format, navigate to [/docs/llms.txt](/docs/llms.txt). This single endpoint aggregates all documentation content into a machine-readable structure, ideal for bulk processing or ingestion into AI systems. + +:: diff --git a/docs/src/routes/docs/guides/features/+page.md b/docs/src/content/guides/features.md similarity index 98% rename from docs/src/routes/docs/guides/features/+page.md rename to docs/src/content/guides/features.md index 314bed042..f5a30b639 100644 --- a/docs/src/routes/docs/guides/features/+page.md +++ b/docs/src/content/guides/features.md @@ -1,4 +1,7 @@ -# Features +--- +title: Features +order: 1 +--- > WIP diff --git a/docs/src/routes/docs/guides/layers/+page.md b/docs/src/content/guides/layers.md similarity index 95% rename from docs/src/routes/docs/guides/layers/+page.md rename to docs/src/content/guides/layers.md index 7f6cfe5de..dacedb03d 100644 --- a/docs/src/routes/docs/guides/layers/+page.md +++ b/docs/src/content/guides/layers.md @@ -1,11 +1,14 @@ +--- +title: Layers +order: 2 +--- + -# Layers - LayerChart provides first-class support for different types of layers including [Svg](/docs/components/Svg), [Html](/docs/components/Html), and [Canvas](/docs/components/Canvas) via [Layer](/docs/components/Layer) and [Primitive](/docs/guides/primitives) components. Each layer type provides unique and overlapping feature sets. LayerChart supports using layers of different types within the same chart to leverage a type's strengths or workaround a weakness. diff --git a/docs/src/routes/docs/guides/layers/FeatureTable.svelte b/docs/src/content/guides/layers/FeatureTable.svelte similarity index 100% rename from docs/src/routes/docs/guides/layers/FeatureTable.svelte rename to docs/src/content/guides/layers/FeatureTable.svelte diff --git a/docs/src/routes/docs/guides/layers/features.ts b/docs/src/content/guides/layers/features.ts similarity index 100% rename from docs/src/routes/docs/guides/layers/features.ts rename to docs/src/content/guides/layers/features.ts diff --git a/docs/src/routes/docs/guides/primitives/+page.md b/docs/src/content/guides/primitives.md similarity index 96% rename from docs/src/routes/docs/guides/primitives/+page.md rename to docs/src/content/guides/primitives.md index 1b332b23f..083f08e96 100644 --- a/docs/src/routes/docs/guides/primitives/+page.md +++ b/docs/src/content/guides/primitives.md @@ -1,3 +1,8 @@ +--- +title: Primitives +order: 3 +--- + -# Primitives - A collection of components which support rendering within different layer types including `Svg`, `Canvas`, or `Html`. Support for each layer type is dependent on the primitive's feature needs and capabilities of the layer type. diff --git a/docs/src/routes/docs/guides/scales/+page.md b/docs/src/content/guides/scales.md similarity index 98% rename from docs/src/routes/docs/guides/scales/+page.md rename to docs/src/content/guides/scales.md index 4ed81aa27..152d3f979 100644 --- a/docs/src/routes/docs/guides/scales/+page.md +++ b/docs/src/content/guides/scales.md @@ -1,9 +1,14 @@ +--- +title: Scales +order: 5 +--- + -# Scales - ## What is a scale? At its essence, a scale is a function that maps data values (`domain`) to pixel or color values (`range`) on a per-dimension basis (`x`, `y`, `color`, etc). diff --git a/docs/src/routes/docs/guides/scales/DomainRangeChart.svelte b/docs/src/content/guides/scales/DomainRangeChart.svelte similarity index 100% rename from docs/src/routes/docs/guides/scales/DomainRangeChart.svelte rename to docs/src/content/guides/scales/DomainRangeChart.svelte diff --git a/docs/src/routes/docs/guides/scales/ResizableRect.svelte b/docs/src/content/guides/scales/ResizableRect.svelte similarity index 100% rename from docs/src/routes/docs/guides/scales/ResizableRect.svelte rename to docs/src/content/guides/scales/ResizableRect.svelte diff --git a/docs/src/routes/docs/guides/simplified-charts/+page.md b/docs/src/content/guides/simplified-charts.md similarity index 98% rename from docs/src/routes/docs/guides/simplified-charts/+page.md rename to docs/src/content/guides/simplified-charts.md index efe17850c..1f6572872 100644 --- a/docs/src/routes/docs/guides/simplified-charts/+page.md +++ b/docs/src/content/guides/simplified-charts.md @@ -1,3 +1,8 @@ +--- +title: Simplified Charts +order: 4 +--- + -# Simplified charts - The LayerChart project was written to offer options for both flexibility/complexity as well as approachablilty/simplicity. This brings us to a decision as you start your first LayerChart. ## Use `` or `Simple Chart`. diff --git a/docs/src/routes/docs/guides/state/+page.md b/docs/src/content/guides/state.md similarity index 60% rename from docs/src/routes/docs/guides/state/+page.md rename to docs/src/content/guides/state.md index f48eca287..6b4c6f218 100644 --- a/docs/src/routes/docs/guides/state/+page.md +++ b/docs/src/content/guides/state.md @@ -1,4 +1,7 @@ -# State / Context +--- +title: State / Context +order: 6 +--- > TODO diff --git a/docs/src/routes/docs/guides/styles/+page.md b/docs/src/content/guides/styles.md similarity index 94% rename from docs/src/routes/docs/guides/styles/+page.md rename to docs/src/content/guides/styles.md index f84da58aa..039b2eb38 100644 --- a/docs/src/routes/docs/guides/styles/+page.md +++ b/docs/src/content/guides/styles.md @@ -1,10 +1,13 @@ -# Styling +--- +title: Styling +order: 7 +--- ## Colors Colors represent the main style requirement for Layerchart. -Instead of requiring explicit color props for each element, LayerChart leverages CSS’s [CSS currentColor](https://www.digitalocean.com/community/tutorials/css-currentcolor) under the hood. This allows developers to style charts using familiar, standard CSS color utilities, rather than targeting different attributes for each rendering layer (svg, canvas, or html). +Instead of requiring explicit color props for each element, LayerChart leverages CSS's [CSS currentColor](https://www.digitalocean.com/community/tutorials/css-currentcolor) under the hood. This allows developers to style charts using familiar, standard CSS color utilities, rather than targeting different attributes for each rendering layer (svg, canvas, or html). Color is simply inherited and propagated through the component tree, and LayerChart automatically applies it appropriately for each display layer—using `fill` or `stroke` for SVG, `fillStyle`, `fillRect` for canvas, and `color` or `background-color` for HTML. @@ -212,7 +215,7 @@ Picking a color isn't easy. Picking many colors that appear cohesive is even tou more info [Color Schemes](/docs/components/ColorRamp#schemes) :: -:example{ path="./color-schemes.svelte" noResize showCode highight="40" } +:example{ path="./styles/color-schemes.svelte" noResize showCode highight="40" } #### Data Driven Colors (choropleth, color prop on data for pie chart, etc) @@ -253,4 +256,4 @@ more info [Color Schemes](/docs/components/ColorRamp#schemes) Chart padding is the only other commonly styled element. `Can xPadding and yPadding be added to example below?` -:example{ path="./padding.svelte" noResize } +:example{ path="./styles/padding.svelte" noResize } diff --git a/docs/src/routes/docs/guides/styles/color-schemes.svelte b/docs/src/content/guides/styles/color-schemes.svelte similarity index 100% rename from docs/src/routes/docs/guides/styles/color-schemes.svelte rename to docs/src/content/guides/styles/color-schemes.svelte diff --git a/docs/src/routes/docs/guides/styles/padding.svelte b/docs/src/content/guides/styles/padding.svelte similarity index 100% rename from docs/src/routes/docs/guides/styles/padding.svelte rename to docs/src/content/guides/styles/padding.svelte diff --git a/docs/src/demo.spec.ts b/docs/src/demo.spec.ts deleted file mode 100644 index e07cbbd72..000000000 --- a/docs/src/demo.spec.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -describe('sum test', () => { - it('adds 1 + 2 to equal 3', () => { - expect(1 + 2).toBe(3); - }); -}); diff --git a/docs/src/lib/collections.ts b/docs/src/lib/collections.ts new file mode 100644 index 000000000..22aa9ca37 --- /dev/null +++ b/docs/src/lib/collections.ts @@ -0,0 +1,12 @@ +/** + * Sort a collection by optional `order` field first (items with order come before those without), + * then alphabetically by `name`. + */ +export function sortCollection(items: T[]): T[] { + return items.slice().sort((a, b) => { + if (a.order !== undefined && b.order !== undefined) return a.order - b.order; + if (a.order !== undefined) return -1; + if (b.order !== undefined) return 1; + return a.name.localeCompare(b.name); + }); +} diff --git a/docs/src/lib/components/Code.svelte b/docs/src/lib/components/Code.svelte index 9173dc579..6616ba220 100644 --- a/docs/src/lib/components/Code.svelte +++ b/docs/src/lib/components/Code.svelte @@ -6,7 +6,7 @@ createHighlighter({ themes: ['github-light-default', 'github-dark-default'], - langs: ['svelte', 'javascript', 'ts', 'typescript', 'json', 'sh'] + langs: ['svelte', 'javascript', 'ts', 'typescript', 'json', 'sh', 'md'] }).then((h) => { highlighter = h; }); diff --git a/docs/src/lib/components/DocsMenu.svelte b/docs/src/lib/components/DocsMenu.svelte index 49b3b95cf..bf5aacbff 100644 --- a/docs/src/lib/components/DocsMenu.svelte +++ b/docs/src/lib/components/DocsMenu.svelte @@ -2,11 +2,13 @@ import { NavItem, type IconProp } from 'svelte-ux'; import { flatGroup } from 'd3-array'; - import { allComponents, allUtils } from 'content-collections'; + import { allComponents, allUtils, allGuides } from 'content-collections'; + import { sortCollection } from '$lib/collections'; import { page } from '$app/state'; import { sortFunc } from '@layerstack/utils'; import { cls } from '@layerstack/tailwind'; + import LucideBot from '~icons/lucide/bot'; import LucideCompass from '~icons/lucide/compass'; import LucideGalleryVertical from '~icons/lucide/gallery-vertical'; import LucideGalleryHorizontalEnd from '~icons/lucide/gallery-horizontal-end'; @@ -21,15 +23,7 @@ let { onItemClick, class: className }: { onItemClick?: () => void; class?: string } = $props(); - const guides = [ - { name: 'Features', path: 'features' }, - { name: 'Layers', path: 'layers' }, - { name: 'Primitives', path: 'primitives' }, - { name: 'Simplified charts', path: 'simplified-charts' }, - { name: 'Scales', path: 'scales' }, - { name: 'State', path: 'state' }, - { name: 'Styles', path: 'styles' } - ]; + const guides = sortCollection(allGuides.filter((g) => !g.draft)); const componentsByCategory = flatGroup(allComponents, (d) => d.category?.toLowerCase()) .filter(([category]) => category !== 'examples') @@ -84,7 +78,7 @@
{#each guides as guide} - {@render navItem({ label: guide.name, path: `/docs/guides/${guide.path}` })} + {@render navItem({ label: guide.name, path: `/docs/guides/${guide.slug}` })} {/each}
@@ -97,17 +91,7 @@

{category}

- {#each components.sort((a, b) => { - // If both have order, sort by order - if (a.order !== undefined && b.order !== undefined) { - return a.order - b.order; - } - // Items with order come first - if (a.order !== undefined) return -1; - if (b.order !== undefined) return 1; - // Both without order, sort alphabetically by name - return a.name.localeCompare(b.name); - }) as component} + {#each sortCollection(components) as component} {@render navItem({ label: component.name, path: `/docs/components/${component.slug}` })} {/each}
diff --git a/docs/src/lib/components/Example.svelte b/docs/src/lib/components/Example.svelte index e15b2313f..099623a12 100644 --- a/docs/src/lib/components/Example.svelte +++ b/docs/src/lib/components/Example.svelte @@ -46,6 +46,11 @@ * Resolve a relative or absolute path to a full path from /src */ function resolveExamplePath(examplePath: string, currentPath: string): string { + const isGuide = currentPath.startsWith('/docs/guides/'); + if (isGuide && (examplePath.startsWith('./') || !examplePath.startsWith('/'))) { + const relativePath = examplePath.startsWith('./') ? examplePath.slice(2) : examplePath; + return `/src/content/guides/${relativePath}`; + } if (examplePath.startsWith('./')) { return `/src/routes${currentPath}/${examplePath.slice(2)}`; } else if (examplePath.startsWith('/')) { diff --git a/docs/src/lib/components/OpenWithButton.svelte b/docs/src/lib/components/OpenWithButton.svelte new file mode 100644 index 000000000..87a7f665c --- /dev/null +++ b/docs/src/lib/components/OpenWithButton.svelte @@ -0,0 +1,194 @@ + + + + + + + + + + (openSourceModal = false)} + class="max-h-[98dvh] md:max-h-[90dvh] max-w-[98vw] md:max-w-[90vw] grid grid-rows-[auto_1fr_auto]" +> +
+
+
Source
+
{metadata?.sourceUrl}
+
+ + {#if metadata?.sourceUrl} + + {/if} +
+ +
+ +
+ +
+ +
+
+ + (openMarkdownModal = false)} + class="max-h-[98dvh] md:max-h-[90dvh] max-w-[98vw] md:max-w-[90vw] grid grid-rows-[auto_1fr_auto]" +> +
+
+
Page Markdown
+
{page.url.href}/llms.txt
+
+ + +
+ +
+ +
+ +
+ +
+
diff --git a/docs/src/lib/examples.ts b/docs/src/lib/examples.ts index f886479a9..9b1dcf3d3 100644 --- a/docs/src/lib/examples.ts +++ b/docs/src/lib/examples.ts @@ -69,11 +69,17 @@ export async function loadExampleByPath(resolvedPath: string): Promise('/src/routes/**/*.svelte'); - const rawModules = import.meta.glob<{ default: string }>('/src/routes/**/*.svelte', { - query: '?raw', - import: 'default' - }); + const modules = import.meta.glob<{ default: Component }>([ + '/src/routes/**/*.svelte', + '/src/content/**/*.svelte' + ]); + const rawModules = import.meta.glob<{ default: string }>( + ['/src/routes/**/*.svelte', '/src/content/**/*.svelte'], + { + query: '?raw', + import: 'default' + } + ); const componentModule = await modules[resolvedPath]?.(); const rawSource = await rawModules[resolvedPath]?.(); diff --git a/docs/src/lib/llms/utils.test.ts b/docs/src/lib/llms/utils.test.ts new file mode 100644 index 000000000..194e2554a --- /dev/null +++ b/docs/src/lib/llms/utils.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect } from 'vitest'; +import { allComponents, allUtils, allGuides } from 'content-collections'; +import { + generateLlmsTxt, + generateFullLlmsTxt, + generateComponentMarkdown, + generateUtilMarkdown, + generateGuideMarkdown, + generateExampleMarkdown, + markdownResponse +} from './utils.js'; + +describe('llms.txt endpoints', () => { + describe('/llms.txt - generateLlmsTxt', () => { + const output = generateLlmsTxt(); + + it('should contain header with library description', () => { + expect(output).toContain('# LayerChart Documentation for LLMs'); + expect(output).toContain('composable charting library for Svelte'); + }); + + it('should contain Guides section', () => { + expect(output).toContain('## Guides'); + expect(output).toContain('Getting Started'); + }); + + it('should contain Components section with all components', () => { + expect(output).toContain('## Components'); + for (const component of allComponents) { + expect(output).toContain(`[${component.name}]`); + } + }); + + it('should contain Utilities section with all utilities', () => { + expect(output).toContain('## Utilities'); + for (const util of allUtils) { + expect(output).toContain(`[${util.name}]`); + } + }); + + it('should contain Examples section', () => { + expect(output).toContain('## Examples'); + }); + + it('should link to llms.txt endpoints', () => { + expect(output).toContain('/llms.txt)'); + }); + }); + + describe('/docs/llms.txt - generateFullLlmsTxt', () => { + const output = generateFullLlmsTxt(); + + it('should contain header with library description', () => { + expect(output).toContain('# LayerChart Full Documentation for LLMs'); + expect(output).toContain('composable charting library for Svelte'); + }); + + it('should contain Guides section', () => { + expect(output).toContain('## Guides'); + expect(output).toContain('Getting Started'); + }); + + it('should contain Components heading with separator', () => { + expect(output).toContain('---'); + expect(output).toContain('# Components'); + }); + + it('should contain all components as inline sections', () => { + for (const component of allComponents) { + expect(output).toContain(`## ${component.name}`); + } + }); + + it('should contain Utilities heading with separator', () => { + expect(output).toContain('---'); + expect(output).toContain('# Utilities'); + }); + + it('should contain all utilities as inline sections', () => { + for (const util of allUtils) { + expect(output).toContain(`## ${util.name}`); + } + }); + }); + + describe('/docs/getting-started/llms.txt - generateGuideMarkdown', () => { + const output = generateGuideMarkdown({ name: 'getting-started', title: 'Getting Started' }); + + it('should contain Getting Started heading', () => { + expect(output).toContain('# Getting Started'); + }); + + it('should contain installation/setup content', () => { + // Getting started guide should have some installation instructions + expect(output.length).toBeGreaterThan(100); + }); + }); + + describe('/docs/guides/[name]/llms.txt - generateGuideMarkdown', () => { + const nonDraftGuides = allGuides.filter((g) => !g.draft); + + it('should generate markdown for each non-draft guide', () => { + for (const guide of nonDraftGuides) { + const output = generateGuideMarkdown({ name: guide.slug }); + expect(output).toContain(`# ${guide.name}`); + expect(output.length).toBeGreaterThan(50); + } + }); + + it('should throw for non-existent guide', () => { + expect(() => generateGuideMarkdown({ name: 'non-existent-guide' })).toThrow(); + }); + }); + + describe('/docs/components/[name]/llms.txt - generateComponentMarkdown', () => { + it('should generate markdown for each component', () => { + for (const component of allComponents) { + const output = generateComponentMarkdown(component, { inlineExamples: true }); + + // Title + expect(output).toContain(`# ${component.name}`); + + // Description (if present) + if (component.description) { + expect(output).toContain(component.description); + } + } + }); + + it('should include category when present', () => { + const withCategory = allComponents.find((c) => c.category); + if (withCategory) { + const output = generateComponentMarkdown(withCategory, { inlineExamples: true }); + expect(output).toContain(`**Category:** ${withCategory.category}`); + } + }); + + it('should include layers when present', () => { + const withLayers = allComponents.find((c) => c.layers && c.layers.length > 0); + if (withLayers) { + const output = generateComponentMarkdown(withLayers, { inlineExamples: true }); + expect(output).toContain('**Supported Layers:**'); + } + }); + + it('should respect headingLevel option', () => { + const component = allComponents[0]; + const output = generateComponentMarkdown(component, { headingLevel: 2 }); + expect(output).toContain(`## ${component.name}`); + expect(output).not.toMatch(new RegExp(`^# ${component.name}`, 'm')); + }); + }); + + describe('/docs/components/[name]/[example]/llms.txt - generateExampleMarkdown', () => { + it('should return null for non-existent example', () => { + const result = generateExampleMarkdown('non-existent', 'fake-example'); + expect(result).toBeNull(); + }); + + it('should generate markdown for a valid component example', () => { + // Extract a real component/example pair from the llms.txt index + const index = generateLlmsTxt(); + const examplesSection = index.slice(index.indexOf('## Examples')); + const exampleMatch = examplesSection.match( + /- \[([^/\]]+)\/([^\]]+)\]\([^)]+\/llms\.txt\)/ + ); + expect(exampleMatch, 'Should find an example link in Examples section').not.toBeNull(); + + const [matched, componentSlug, exampleName] = exampleMatch!; + const result = generateExampleMarkdown(componentSlug, exampleName); + expect( + result, + `generateExampleMarkdown('${componentSlug}', '${exampleName}') returned null. Matched: ${matched}` + ).not.toBeNull(); + expect(result).toContain(`# ${componentSlug} - ${exampleName}`); + expect(result).toContain('## Code'); + expect(result).toContain('```svelte'); + }); + }); + + describe('/docs/utils/[name]/llms.txt - generateUtilMarkdown', () => { + it('should generate markdown for each utility', () => { + for (const util of allUtils) { + const output = generateUtilMarkdown(util, { inlineExamples: true }); + + // Title + expect(output).toContain(`# ${util.name}`); + + // Description (if present) + if (util.description) { + expect(output).toContain(util.description); + } + } + }); + + it('should respect headingLevel option', () => { + const util = allUtils[0]; + const output = generateUtilMarkdown(util, { headingLevel: 2 }); + expect(output).toContain(`## ${util.name}`); + expect(output).not.toMatch(new RegExp(`^# ${util.name}`, 'm')); + }); + }); + + describe('markdownResponse', () => { + it('should return response with correct content-type', () => { + const response = markdownResponse('# Test', 'test.md'); + expect(response.headers.get('Content-Type')).toBe('text/markdown; charset=utf-8'); + }); + + it('should return response with correct content-disposition', () => { + const response = markdownResponse('# Test', 'test.md'); + expect(response.headers.get('Content-Disposition')).toBe( + 'inline; filename="test.md"' + ); + }); + + it('should return the content as body', async () => { + const response = markdownResponse('# Hello World', 'hello.md'); + const text = await response.text(); + expect(text).toBe('# Hello World'); + }); + }); +}); diff --git a/docs/src/lib/llms/utils.ts b/docs/src/lib/llms/utils.ts new file mode 100644 index 000000000..b7779612f --- /dev/null +++ b/docs/src/lib/llms/utils.ts @@ -0,0 +1,678 @@ +import type { ComponentAPI } from '$lib/api-types.js'; +import { allComponents, allUtils, allGuides } from 'content-collections'; +import { sortCollection } from '$lib/collections.js'; + +const exampleSources = import.meta.glob('/src/examples/**/*.svelte', { + eager: true, + query: '?raw', + import: 'default' +}); + +const apiFiles = import.meta.glob('/src/generated/api/*.json', { + eager: true, + query: '?raw', + import: 'default' +}); + +const guideSources = import.meta.glob('/src/content/guides/*.md', { + eager: true, + query: '?raw', + import: 'default' +}); + +const gettingStartedSource = import.meta.glob('/src/routes/docs/getting-started/+page.md', { + eager: true, + query: '?raw', + import: 'default' +}); + +const catalogFiles = import.meta.glob('/src/examples/catalog/*.json', { + eager: true, + query: '?raw', + import: 'default' +}); + +const BASE_URL = 'https://layerchart.com'; + +/** Generate URL for a docs page */ +function docsUrl(type: 'components' | 'utils' | 'guides', slug: string): string { + if (type === 'guides') { + return `${BASE_URL}/docs/${slug}`; + } + return `${BASE_URL}/docs/${type}/${slug}`; +} + +/** Generate URL for an llms.txt endpoint */ +function llmsUrl(type: 'components' | 'utils' | 'guides', slug: string): string { + return `${docsUrl(type, slug)}/llms.txt`; +} + +interface GenerateMarkdownOptions { + /** Heading level for the title. 1 = "#", 2 = "##", etc. Default: 1 */ + headingLevel?: number; + /** Whether to inline all example code blocks. Default: false */ + inlineExamples?: boolean; +} + +/** + * Replace :example{...} directives with inlined code blocks. + * Must be called BEFORE processMarkdownContent. + */ +function inlineExampleDirectives( + content: string, + slug: string, + type: 'components' | 'utils' +): string { + // Remove HTML comments first to avoid inlining commented-out examples + content = content.replace(//g, ''); + + // Cross-component examples: :example{ component="X" name="Y" ... } + // Must run before same-component regex to avoid greedy matching + content = content.replace( + /:example\{\s*component="([^"]+)"\s+name="([^"]+)"[^}]*\}/g, + (_match, component: string, name: string) => { + const raw = getExampleSource('components', component, name); + if (raw) { + return '```svelte\n' + trimCode(raw) + '\n```'; + } + return `See example: [${component}/${name}](${docsUrl('components', component)}/${name})`; + } + ); + + // Same-component examples: :example{ name="Y" ... } + content = content.replace(/:example\{\s*name="([^"]+)"[^}]*\}/g, (_match, name: string) => { + const raw = getExampleSource(type, slug, name); + if (raw) { + return '```svelte\n' + trimCode(raw) + '\n```'; + } + return `See example: ${name}`; + }); + + return content; +} + +/** + * Process markdown content for LLMs by removing custom syntax and converting to vanilla markdown + */ +function processMarkdownContent(content: string): string { + // Remove frontmatter (YAML between --- markers at start of file) + content = content.replace(/^---\n[\s\S]*?\n---\n*/, ''); + + // Remove HTML comments + content = content.replace(//g, ''); + + // Remove Svelte script blocks and components ONLY outside of code blocks + // Split by code blocks, process only non-code-block parts, then rejoin + content = content + .split(/(```[\s\S]*?```)/g) + .map((part, index) => { + // Odd indices are code blocks (matched by the capture group) + if (index % 2 === 1) return part; + // Remove Svelte script blocks + part = part.replace(/]*>[\s\S]*?<\/script>\n*/g, ''); + // Remove Svelte components (self-closing and with content) + part = part.replace(/<[A-Z][a-zA-Z]*[^>]*\/>\n*/g, ''); + part = part.replace(/<[A-Z][a-zA-Z]*[^>]*>[\s\S]*?<\/[A-Z][a-zA-Z]*>\n*/g, ''); + return part; + }) + .join(''); + + // Extract title from code blocks and add as "File:" line before (must run before tabs processing) + content = content.replace(/(```\w*)\s+([^\n]*title="[^"]+")[^\n]*$/gm, (_, lang, meta) => { + const titleMatch = meta.match(/title="([^"]+)"/); + if (titleMatch) { + return `File: ${titleMatch[1]} ${lang}`; + } + return lang; + }); + + // Process tabs - convert to table + content = content.replace( + /:::tabs\{key="([^"]+)"\}\s*([\s\S]*?)(?=\n:::(?:\s*$|\s*\n))\n:::/gm, + (_, key, tabsContent) => { + const tabs: { label: string; content: string }[] = []; + const tabRegex = + /::tab\{label="([^"]+)"[^}]*\}\s*([\s\S]*?)\s*(?=\n\s*::(?:\s*$|\s+))\n\s*::/gm; + let match; + while ((match = tabRegex.exec(tabsContent)) !== null) { + tabs.push({ label: match[1], content: match[2].trim() }); + } + + if (tabs.length === 0) return ''; + + // Build table with capitalized key as header + const header = key.charAt(0).toUpperCase() + key.slice(1); + let table = `| ${header} | Details |\n|-----------|---------|`; + for (const tab of tabs) { + // Clean up content: remove :button syntax, convert to links, unwrap code blocks + const cleanContent = tab.content + .replace(/:button\{label="([^"]+)"\s+href="([^"]+)"[^}]*\}/g, '[$1]($2)') + .replace(/```\w*\n([\s\S]*?)```/g, '$1') // Remove code block fences, keep content + .replace(/\n/g, ' ') + .trim(); + table += `\n| ${tab.label} | ${cleanContent} |`; + } + return table; + } + ); + + // Convert ::note/:::note, ::tip/:::tip, etc. to blockquote (2 or 3 colons) + content = content.replace( + /:{2,3}(note|tip|warning|caution)\s*([\s\S]*?)(?=\n:{2,3}(?:\s*$|\s*\n))\n:{2,3}/gm, + (_, variant, noteContent) => { + return `> ${variant}: ${noteContent.trim()}\n`; + } + ); + + // Convert ::steps to numbered list (convert ## headings to numbered items) + content = content.replace( + /::steps\s*([\s\S]*?)(?=\n::(?:\s*$|\s*\n))\n::/gm, + (_, stepsContent: string) => { + let stepNum = 0; + return stepsContent.replace(/^## (.+)$/gm, (_match: string, heading: string) => { + stepNum++; + return `**${stepNum}. ${heading}**`; + }); + } + ); + + // Remove any remaining standalone :: + content = content.replace(/^::\s*$/gm, ''); + + // Remove :icon syntax, keep text if in brackets, otherwise just remove icon + content = content.replace(/\[:icon\{[^}]+\}\s*([^\]]+)\]/g, '$1'); + content = content.replace(/:icon\{[^}]+\}\s*/g, ''); + + // Convert :example with component to reference link + content = content.replace( + /:example\{component="([^"]+)"\s+name="([^"]+)"[^}]*\}/g, + (_match, comp: string, name: string) => + `See example: [${comp}/${name}](${docsUrl('components', comp)}/${name})` + ); + + // Convert remaining :example directives (same-component, not inlined) to plain text + content = content.replace(/:example\{\s*name="([^"]+)"[^}]*\}/g, 'See example: $1'); + + // Clean up multiple blank lines + content = content.replace(/\n{3,}/g, '\n\n'); + + return content.trim(); +} + +/** + * Trim code to remove module exports and data export statement + */ +function trimCode(code: string): string { + return code + .replace(/[\s\S]*?<\/script>\n*/g, '') + .replace(/\n*\s*export \{ data \};\s*\n*\s*<\/script>/gm, '\n') + .trim(); +} + +/** + * Escape special markdown characters in table cells + */ +function escapeMarkdown(text: string): string { + return text.replace(/\|/g, '\\|').replace(/\n/g, ' ').replace(//g, '>'); +} + +/** + * Generate markdown API table from component properties + */ +function generateApiTable(api: ComponentAPI): string { + if (!api.properties || api.properties.length === 0) { + return ''; + } + + const rows = api.properties.map((prop) => { + const name = prop.required ? `**${prop.name}** (required)` : prop.name; + const type = `\`${escapeMarkdown(prop.type)}\``; + const defaultVal = prop.default ? `\`${escapeMarkdown(prop.default)}\`` : '-'; + const description = prop.description ? escapeMarkdown(prop.description) : '-'; + return `| ${name} | ${type} | ${defaultVal} | ${description} |`; + }); + + return `| Property | Type | Default | Description | +|----------|------|---------|-------------| +${rows.join('\n')}`; +} + +/** + * Generate markdown for a component + */ +export function generateComponentMarkdown( + component: (typeof allComponents)[number], + options: GenerateMarkdownOptions = {} +): string { + const { headingLevel = 1, inlineExamples: shouldInlineExamples = false } = options; + const h = (level: number) => '#'.repeat(level); + + const sections: string[] = []; + + // Title and description + sections.push(`${h(headingLevel)} ${component.name}`); + if (component.description) { + sections.push(component.description); + } + + // Metadata + if (component.category) { + sections.push(`**Category:** ${component.category}`); + } + if (component.layers && component.layers.length > 0) { + sections.push(`**Supported Layers:** ${component.layers.join(', ')}`); + } + + // Documentation content from markdown + if (component.content) { + let rawContent = component.content; + if (shouldInlineExamples) { + rawContent = inlineExampleDirectives(rawContent, component.slug, 'components'); + } + const processed = processMarkdownContent(rawContent); + if (processed) { + sections.push(processed); + } + } + + // Load API + let api: ComponentAPI | null = null; + const apiKey = `/src/generated/api/${component.slug}.json`; + const apiContent = apiFiles[apiKey]; + if (apiContent) { + api = JSON.parse(apiContent); + } + + if (api) { + sections.push(`${h(headingLevel + 1)} API`); + const table = generateApiTable(api); + if (table) { + sections.push(table); + } + + if (api.extends && api.extends.length > 0) { + const extendsList = api.extends.map((e) => `\`${e.fullType}\``).join(', '); + sections.push(`**Extends:** ${extendsList}`); + } + } + + // Examples from catalog + const catalog = getCatalog(component.slug); + if (catalog) { + const examples = catalog.examples as Array<{ name: string; path: string }>; + if (examples && examples.length > 0) { + sections.push(`${h(headingLevel + 1)} Examples`); + const exampleLinks = examples + .map((ex) => `- [${ex.name}](${docsUrl('components', component.slug)}/${ex.name})`) + .join('\n'); + sections.push(exampleLinks); + } + + // TODO: should we include usage examples? + // Additional usage in other component examples (deduplicated) + // const usage = catalog.usage as Array<{ example: string; component: string; path: string }>; + // if (usage && usage.length > 0) { + // const exampleNames = new Set(examples?.map((ex) => ex.name) ?? []); + // const seen = new Set(); + // const uniqueUsage = usage.filter((item) => { + // // Exclude if already shown in main examples + // if (item.component === catalog.component && exampleNames.has(item.example)) { + // return false; + // } + // const key = `${item.component}::${item.example}`; + // if (seen.has(key)) return false; + // seen.add(key); + // return true; + // }); + + // if (uniqueUsage.length > 0) { + // sections.push(`${h(headingLevel + 1)} Additional Usage`); + // const usageLinks = uniqueUsage + // .map( + // (u) => + // `- [${u.component}/${u.example}](${BASE_URL}/docs/components/${u.component}/${u.example})` + // ) + // .join('\n'); + // sections.push(usageLinks); + // } + // } + } + + // Related + if (component.related && component.related.length > 0) { + sections.push(`${h(headingLevel + 1)} Related`); + const relatedLinks = component.related + .map((r) => `- [${r}](${docsUrl('components', r)})`) + .join('\n'); + sections.push(relatedLinks); + } + + return sections.join('\n\n'); +} + +/** + * Generate markdown for a utility + */ +export function generateUtilMarkdown( + util: (typeof allUtils)[number], + options: GenerateMarkdownOptions = {} +): string { + const { headingLevel = 1, inlineExamples: shouldInlineExamples = false } = options; + const h = (level: number) => '#'.repeat(level); + + const sections: string[] = []; + + // Title and description + sections.push(`${h(headingLevel)} ${util.name}`); + if (util.description) { + sections.push(util.description); + } + + // Documentation content from markdown + if (util.content) { + let rawContent = util.content; + if (shouldInlineExamples) { + rawContent = inlineExampleDirectives(rawContent, util.slug, 'utils'); + } + const processed = processMarkdownContent(rawContent); + if (processed) { + sections.push(processed); + } + } + + // Load example + let exampleSource = ''; + if (util.usageExample) { + const key = `/src/examples/utils/${util.slug}/${util.usageExample}.svelte`; + const raw = exampleSources[key]; + if (raw) { + exampleSource = trimCode(raw); + } + } + + if (exampleSource) { + sections.push(`${h(headingLevel + 1)} Example`); + sections.push('```svelte\n' + exampleSource + '\n```'); + } + + // Related + if (util.related && util.related.length > 0) { + sections.push(`${h(headingLevel + 1)} Related`); + const relatedLinks = util.related.map((r) => `- [${r}](${docsUrl('utils', r)})`).join('\n'); + sections.push(relatedLinks); + } + + return sections.join('\n\n'); +} + +/** + * Get sorted guides list with the getting-started entry prepended + */ +function getSortedGuides(): Array<{ slug: string; name: string; description: string }> { + return [ + { + slug: 'getting-started', + name: 'Getting Started', + description: 'Installation and setup guide for LayerChart' + }, + ...sortCollection(allGuides.filter((g) => !g.draft)).map((g) => ({ + slug: `guides/${g.slug}`, + name: g.name, + description: g.description ?? '' + })) + ]; +} + +/** + * Load and generate markdown for a guide. + */ +export function generateGuideMarkdown(options: { + /** The name/slug of the guide (e.g., 'getting-started', 'styles') */ + name: string; + /** Optional explicit title. If not provided, title is extracted from frontmatter, + * falling back to title-casing the name. */ + title?: string; +}): string { + const { name, title: explicitTitle } = options; + + // Look up from the pre-loaded glob maps + let raw: string | undefined; + if (name === 'getting-started') { + raw = gettingStartedSource['/src/routes/docs/getting-started/+page.md']; + } else { + raw = guideSources[`/src/content/guides/${name}.md`]; + } + + if (!raw) { + throw new Error(`Guide "${name}" not found`); + } + + // Extract title from frontmatter if not explicitly provided + let title = explicitTitle; + if (!title) { + const frontmatterMatch = raw.match(/^---\n([\s\S]*?)\n---\n*/); + if (frontmatterMatch) { + const titleMatch = frontmatterMatch[1].match(/^title:\s*(.+)$/m); + if (titleMatch) { + title = titleMatch[1].trim().replace(/^["']|["']$/g, ''); + } + } + } + if (!title) { + title = name + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } + + const content = processMarkdownContent(raw); + return `# ${title}\n\n${content}`; +} + +/** + * Get the raw source for an example svelte file. + */ +function getExampleSource( + type: 'components' | 'utils', + componentSlug: string, + exampleName: string +): string | undefined { + const key = `/src/examples/${type}/${componentSlug}/${exampleName}.svelte`; + return exampleSources[key]; +} + +/** + * Get the parsed catalog JSON for a component. + */ +function getCatalog(componentSlug: string): Record | null { + const key = `/src/examples/catalog/${componentSlug}.json`; + const raw = catalogFiles[key]; + if (!raw) return null; + return JSON.parse(raw); +} + +/** + * Get all example file paths from the glob map (for listing). + */ +function getAllExamplePaths(): string[] { + return Object.keys(exampleSources) + .filter((key) => key.startsWith('/src/examples/components/')) + .sort(); +} + +interface CollectionListOptions { + title: string; + items: Array<{ slug: string; name: string; description?: string }>; + type: 'components' | 'utils' | 'guides'; +} + +/** + * Generate a markdown section listing collection items as links to their llms.txt endpoints. + */ +function generateCollectionListSection(options: CollectionListOptions): string { + const { title, items, type } = options; + + const fallbackLabel = + type === 'components' ? 'component' : type === 'utils' ? 'utility' : 'guide'; + + const lines = items + .filter((item) => item.slug && item.name) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((item) => { + const description = item.description || `Documentation for ${item.name} ${fallbackLabel}`; + return `- [${item.name}](${llmsUrl(type, item.slug)}): ${description}`; + }); + + return `## ${title}\n\n${lines.join('\n')}`; +} + +/** + * Generate markdown for a single component example + */ +export function generateExampleMarkdown(componentSlug: string, exampleName: string): string | null { + const raw = getExampleSource('components', componentSlug, exampleName); + if (!raw) return null; + + const exampleSource = trimCode(raw); + const component = allComponents.find((c) => c.slug === componentSlug); + + // Find components used in this example from the catalog + const catalog = getCatalog(componentSlug); + const exampleInfo = ( + catalog?.examples as + | Array<{ name: string; components: Array<{ component: string }> }> + | undefined + )?.find((e) => e.name === exampleName); + const usedComponentNames = [...new Set(exampleInfo?.components.map((c) => c.component) ?? [])]; + const usedComponents = usedComponentNames + .map((c) => allComponents.find((ac) => ac.name === c)) + .filter((c) => c != null); + + const sections: string[] = []; + + sections.push(`# ${componentSlug} - ${exampleName}`); + + if (component?.description) { + sections.push(component.description); + } + + sections.push('## Code'); + sections.push('```svelte\n' + exampleSource + '\n```'); + + if (usedComponents.length > 0) { + sections.push('## Components'); + const links = usedComponents + .map((comp) => `- [${comp.name}](${llmsUrl('components', comp.slug)})`) + .join('\n'); + sections.push(links); + } + + return sections.join('\n\n'); +} + +/** + * Generate the llms.txt index with links to all documentation + */ +export function generateLlmsTxt(): string { + const sections: string[] = []; + + // Header + sections.push(`# LayerChart Documentation for LLMs + +> LayerChart is a powerful, composable charting library for Svelte built on top of D3. + +This file contains links to LLM-optimized documentation in markdown format.`); + + // Guides + sections.push( + generateCollectionListSection({ title: 'Guides', items: getSortedGuides(), type: 'guides' }) + ); + + // Components + sections.push( + generateCollectionListSection({ + title: 'Components', + items: allComponents, + type: 'components' + }) + ); + + // Utilities + sections.push( + generateCollectionListSection({ title: 'Utilities', items: allUtils, type: 'utils' }) + ); + + // Examples + const paths = getAllExamplePaths(); + const examples: string[] = []; + for (const path of paths) { + const match = path.match(/\/src\/examples\/components\/([^/]+)\/([^/]+)\.svelte$/); + if (match) { + const [, componentName, exampleName] = match; + examples.push( + `- [${componentName}/${exampleName}](${llmsUrl('components', `${componentName}/${exampleName}`)}): Example code for ${componentName}` + ); + } + } + if (examples.length > 0) { + sections.push(`## Examples\n\n${examples.join('\n')}`); + } + + return sections.join('\n\n'); +} + +/** + * Generate the full llms.txt with all components and utilities + */ +export function generateFullLlmsTxt(): string { + const sections: string[] = []; + + // Header + sections.push(`# LayerChart Full Documentation for LLMs + +> LayerChart is a powerful, composable charting library for Svelte built on top of D3. + +This file contains the complete LLM-optimized documentation for all components and utilities.`); + + sections.push( + generateCollectionListSection({ title: 'Guides', items: getSortedGuides(), type: 'guides' }) + ); + + // Components section - full content + sections.push('---'); + sections.push('# Components'); + + const sortedComponents = allComponents + .filter((c) => c.slug && c.name) + .sort((a, b) => a.name.localeCompare(b.name)); + + for (const component of sortedComponents) { + sections.push(generateComponentMarkdown(component, { headingLevel: 2 })); + } + + // Utilities section - full content + sections.push('---'); + sections.push('# Utilities'); + + const sortedUtils = allUtils + .filter((u) => u.slug && u.name) + .sort((a, b) => a.name.localeCompare(b.name)); + + for (const util of sortedUtils) { + sections.push(generateUtilMarkdown(util, { headingLevel: 2 })); + } + + return sections.join('\n\n'); +} + +/** + * Create a markdown response + */ +export function markdownResponse(content: string, filename: string): Response { + return new Response(content, { + headers: { + 'Content-Type': 'text/markdown; charset=utf-8', + 'Content-Disposition': `inline; filename="${filename}"` + } + }); +} diff --git a/docs/src/lib/markdown/components/Note.svelte b/docs/src/lib/markdown/components/Note.svelte index c74d80d7d..29ea7da13 100644 --- a/docs/src/lib/markdown/components/Note.svelte +++ b/docs/src/lib/markdown/components/Note.svelte @@ -25,14 +25,15 @@
p]:my-2', className )} style:--color={color} {...restProps} > - {@render children?.()} +
+ {@render children?.()} +
diff --git a/docs/src/lib/markdown/components/blockquote.svelte b/docs/src/lib/markdown/components/blockquote.svelte index f9804cf3c..8361807bb 100644 --- a/docs/src/lib/markdown/components/blockquote.svelte +++ b/docs/src/lib/markdown/components/blockquote.svelte @@ -9,7 +9,6 @@ class={cls( 'bg-surface-content/5 border-l-4 px-4 py-2 my-4 text-sm rounded-r', '[&>a]:font-medium [&>a]:underline [&>a]:decoration-dashed [&>a]:decoration-primary/50 [&>a]:underline-offset-2', - '[&>p]:my-2 [&>p]:first:my-2', className )} {...restProps} diff --git a/docs/src/lib/markdown/components/p.svelte b/docs/src/lib/markdown/components/p.svelte index 0717f0bad..a23bd1459 100644 --- a/docs/src/lib/markdown/components/p.svelte +++ b/docs/src/lib/markdown/components/p.svelte @@ -5,6 +5,6 @@ let { class: className, children, ...restProps }: HTMLAttributes = $props(); -

+

&]:my-5 leading-relaxed', className)} {...restProps}> {@render children?.()}

diff --git a/docs/src/lib/markdown/utils.ts b/docs/src/lib/markdown/utils.ts index 2e012cf84..cc58aecfb 100644 --- a/docs/src/lib/markdown/utils.ts +++ b/docs/src/lib/markdown/utils.ts @@ -6,7 +6,9 @@ import { type Component as ComponentMetadata, type Example as ExampleMetadata, allUtils, - type Util as UtilMetadata + type Util as UtilMetadata, + allGuides, + type Guide as GuideMetadata } from 'content-collections'; import type { Examples } from '$lib/types.js'; @@ -20,11 +22,11 @@ import { loadExample, loadExampleByPath } from '$lib/examples.js'; */ export async function getMarkdownComponent( slug: string = 'index', - type: 'components' | 'utils' = 'components' + type: 'components' | 'utils' | 'guides' = 'components' ) { const modules = import.meta.glob<{ default: Component; - metadata: ComponentMetadata | ExampleMetadata | UtilMetadata; + metadata: ComponentMetadata | ExampleMetadata | UtilMetadata | GuideMetadata; }>('/src/content/**/*.md'); let doc: Awaited> | null = null; @@ -52,8 +54,11 @@ export async function getMarkdownComponent( */ function getMetadata( slug: string, - type: 'components' | 'utils' = 'components' -): ComponentMetadata | UtilMetadata { + type: 'components' | 'utils' | 'guides' = 'components' +): ComponentMetadata | UtilMetadata | GuideMetadata { + if (type === 'guides') { + return allGuides.find((g) => g.slug === slug) as any; + } if (type === 'utils') { return allUtils.find((u) => u.slug === slug) as any; } @@ -66,7 +71,12 @@ function getMetadata( * @param currentPath - The current page URL pathname (e.g., "/docs/guides/styles") * @returns The resolved path (e.g., "/src/routes/docs/guides/styles/color-schemes.svelte") */ -export function resolveExamplePath(path: string, currentPath: string): string { +export function resolveExamplePath(path: string, currentPath: string, type?: string): string { + if (type === 'guides' && (path.startsWith('./') || !path.startsWith('/'))) { + // For guides, resolve relative to src/content/guides/ + const relativePath = path.startsWith('./') ? path.slice(2) : path; + return `/src/content/guides/${relativePath}`; + } if (path.startsWith('./')) { return `/src/routes${currentPath}/${path.slice(2)}`; } else if (path.startsWith('/')) { @@ -91,7 +101,7 @@ export function resolveExamplePath(path: string, currentPath: string): string { export async function loadExamplesFromMarkdown( markdownContent: string, defaultComponent?: string, - type: 'components' | 'utils' = 'components', + type: 'components' | 'utils' | 'guides' = 'components', currentPath?: string ): Promise { // Extract all from markdown content @@ -111,7 +121,7 @@ export async function loadExamplesFromMarkdown( if (path && currentPath) { // Path-based example - const resolvedPath = resolveExamplePath(path, currentPath); + const resolvedPath = resolveExamplePath(path, currentPath, type); pathExamples.push({ path, resolvedPath }); } else { // Component/name-based example @@ -128,7 +138,11 @@ export async function loadExamplesFromMarkdown( // Load component-based examples in parallel await Promise.all( componentExamples.map(async (ex) => { - const loaded = await loadExample(ex.component, ex.name, type); + const loaded = await loadExample( + ex.component, + ex.name, + type === 'guides' ? 'components' : type + ); if (loaded) { if (!examples[ex.component]) { examples[ex.component] = {}; @@ -153,3 +167,4 @@ export async function loadExamplesFromMarkdown( return examples; } + diff --git a/docs/src/routes/+page.svelte b/docs/src/routes/+page.svelte index 81b094fe9..4f0378754 100644 --- a/docs/src/routes/+page.svelte +++ b/docs/src/routes/+page.svelte @@ -12,7 +12,6 @@ import CustomBluesky from '~icons/custom-brands/bluesky'; import CustomDiscord from '~icons/custom-brands/discord'; - const links = [ { label: 'Home', href: '/' }, { label: 'Docs', href: '/docs' } @@ -272,8 +271,9 @@
diff --git a/docs/src/routes/docs/components/[name]/+layout.svelte b/docs/src/routes/docs/components/[name]/+layout.svelte index cd50a4c2b..3c82e29c9 100644 --- a/docs/src/routes/docs/components/[name]/+layout.svelte +++ b/docs/src/routes/docs/components/[name]/+layout.svelte @@ -2,14 +2,13 @@ import { getSettings } from 'layerchart'; import { Button, Menu, Switch, Toggle, ToggleGroup, ToggleOption, Tooltip } from 'svelte-ux'; import { toTitleCase } from '@layerstack/utils'; + import OpenWithButton from '$lib/components/OpenWithButton.svelte'; - import ViewSourceButton from '$lib/components/ViewSourceButton.svelte'; import { examples } from '$lib/context.js'; import { loadExample } from '$lib/examples.js'; import { page } from '$app/state'; import LucideSettings from '~icons/lucide/settings'; - import LucideCode from '~icons/lucide/code'; import LucideChevronLeft from '~icons/lucide/chevron-left'; import LucideChevronRight from '~icons/lucide/chevron-right'; @@ -129,14 +128,7 @@
{metadata.description}
- {#if 'source' in metadata} - - {/if} + +{#if data.metadata.title !== 'LLMs'} +
+ +
+{/if} + + + {#snippet pending()} + loading... + {/snippet} + + {@render children()} + diff --git a/docs/src/routes/docs/guides/[name]/+layout.ts b/docs/src/routes/docs/guides/[name]/+layout.ts new file mode 100644 index 000000000..9501a78a4 --- /dev/null +++ b/docs/src/routes/docs/guides/[name]/+layout.ts @@ -0,0 +1,23 @@ +import { getMarkdownComponent, loadExamplesFromMarkdown } from '$lib/markdown/utils.js'; + +export const load = async ({ params, parent, url }) => { + const parentData = await parent(); + + const { PageComponent, metadata } = await getMarkdownComponent(params.name, 'guides'); + + // Load any examples referenced in the markdown content + const pageExamples = await loadExamplesFromMarkdown( + metadata.content, + undefined, + 'guides', + url.pathname + ); + + const examples = { ...parentData.examples, ...pageExamples }; + + return { + PageComponent, + metadata, + examples + }; +}; diff --git a/docs/src/routes/docs/guides/[name]/+page.svelte b/docs/src/routes/docs/guides/[name]/+page.svelte new file mode 100644 index 000000000..7dc0dfc01 --- /dev/null +++ b/docs/src/routes/docs/guides/[name]/+page.svelte @@ -0,0 +1,6 @@ + + + diff --git a/docs/src/routes/docs/guides/[name]/llms.txt/+server.ts b/docs/src/routes/docs/guides/[name]/llms.txt/+server.ts new file mode 100644 index 000000000..9d3099d46 --- /dev/null +++ b/docs/src/routes/docs/guides/[name]/llms.txt/+server.ts @@ -0,0 +1,16 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { generateGuideMarkdown, markdownResponse } from '$lib/llms/utils.js'; + +export const GET: RequestHandler = async ({ params }) => { + const { name } = params; + + let markdown: string; + try { + markdown = generateGuideMarkdown({ name }); + } catch (e) { + error(404, `Guide "${name}" not found`); + } + + return markdownResponse(markdown, `${name}.md`); +}; diff --git a/docs/src/routes/docs/llms.txt/+server.ts b/docs/src/routes/docs/llms.txt/+server.ts new file mode 100644 index 000000000..db6a4a9ac --- /dev/null +++ b/docs/src/routes/docs/llms.txt/+server.ts @@ -0,0 +1,6 @@ +import type { RequestHandler } from './$types'; +import { generateFullLlmsTxt, markdownResponse } from '$lib/llms/utils.js'; + +export const GET: RequestHandler = async () => { + return markdownResponse(generateFullLlmsTxt(), 'llms-full.md'); +}; diff --git a/docs/src/routes/docs/utils/[name]/+layout.svelte b/docs/src/routes/docs/utils/[name]/+layout.svelte index ae00dddfe..9acb7e2a3 100644 --- a/docs/src/routes/docs/utils/[name]/+layout.svelte +++ b/docs/src/routes/docs/utils/[name]/+layout.svelte @@ -1,11 +1,10 @@