diff --git a/app.arc b/app.arc index e13b5261..99f0919a 100644 --- a/app.arc +++ b/app.arc @@ -12,6 +12,8 @@ fingerprint true get / get /docs/:lang/* get /api/package +get /llms.txt +get /llms-full.txt any /* @plugins diff --git a/eslint.config.mjs b/eslint.config.mjs index 9421567d..53ae3e1a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -7,20 +7,20 @@ export default [ languageOptions: { parserOptions: { ecmaVersion: 2022, - sourceType: 'module' - } + sourceType: 'module', + }, }, plugins: { - import: importPlugin.flatConfigs.recommended.plugins.import + import: importPlugin.flatConfigs.recommended.plugins.import, }, rules: { 'import/no-commonjs': 'error', 'import/extensions': [ 'error', - 'ignorePackages' + 'ignorePackages', ], // Additive to our old `import` config, but everything seems quite sane! ...importPlugin.flatConfigs.recommended.rules, - } + }, }, ] diff --git a/public/index.js b/public/index.js index 8f250851..dfb6979e 100644 --- a/public/index.js +++ b/public/index.js @@ -1,5 +1,5 @@ /* eslint-env browser */ -(function (){ +(function () { const activeLink = document.querySelector('a.active') const main = document.getElementById('main') const menuButton = document.getElementById('menu-button') @@ -11,7 +11,7 @@ if (activeLink) activeLink.scrollIntoView({ behavior: 'smooth', - block: 'center' + block: 'center', }) // Toggle sidebar on mobile @@ -30,6 +30,32 @@ localStorage.setItem('theme', targetTheme) } + // Copy Markdown button for LLM use + const copyMarkdownBtn = document.getElementById('copy-markdown-btn') + if (copyMarkdownBtn) { + const svgCopy = '' + const svgCheck = '' + copyMarkdownBtn.onclick = () => { + const markdown = copyMarkdownBtn.getAttribute('data-markdown') + .replace(/"/g, '"') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') + const iconSpan = copyMarkdownBtn.querySelector('.icon') + + navigator.clipboard.writeText(markdown).then( + () => { + iconSpan.innerHTML = svgCheck + setTimeout(() => iconSpan.innerHTML = svgCopy, 2000) + }, + () => { + iconSpan.innerHTML = 'Error!' + setTimeout(() => iconSpan.innerHTML = svgCopy, 2000) + }, + ) + } + } + // Copy-Paste function for code blocks const buttonClassList = [ 'icon', @@ -65,7 +91,7 @@ target.innerHTML = svgCheck setTimeout(() => target.innerHTML = svgCopy, 2000) }, - () => target.innerHTML = 'Error copying!' + () => target.innerHTML = 'Error copying!', ) } diff --git a/src/http/get-docs-000lang-catchall/index.mjs b/src/http/get-docs-000lang-catchall/index.mjs index 9856b3a8..f66eaf57 100644 --- a/src/http/get-docs-000lang-catchall/index.mjs +++ b/src/http/get-docs-000lang-catchall/index.mjs @@ -74,6 +74,7 @@ async function handler (req) { active, editURL, lang, + markdown: md, path, scripts: [ '/index.js', diff --git a/src/http/get-llms_full_txt/index.mjs b/src/http/get-llms_full_txt/index.mjs new file mode 100644 index 00000000..5806a045 --- /dev/null +++ b/src/http/get-llms_full_txt/index.mjs @@ -0,0 +1,172 @@ +import { readFileSync, readdirSync, existsSync } from 'fs' +import { join, relative, dirname } from 'path' +import { fileURLToPath } from 'url' +import arc from '@architect/functions' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const BASE_URL = 'https://arc.codes' + +// Configuration +const config = { + // Files or directories to skip + excludes: [ '.DS_Store', 'node_modules', 'table-of-contents.mjs' ], +} + +/** + * Cleans markdown content for LLM consumption + * @param {string} content - Raw markdown content + * @returns {string} Cleaned content + */ +function cleanMarkdownContent (content) { + return content + // Remove frontmatter + .replace(/^---[\s\S]*?---\n*/m, '') + // Remove custom HTML components but keep content + .replace(/]*>/g, '') + .replace(/<\/arc-viewer>/g, '') + .replace(/]*label="([^"]*)"[^>]*>/g, '**$1:**\n') + .replace(/<\/arc-tab>/g, '') + .replace(/]*slot[^>]*>/g, '') + .replace(/<\/div>/g, '') + .replace(/
/g, '') + .replace(/<\/h5>/g, '') + // Remove multiple newlines + .replace(/\n{3,}/g, '\n\n') + .trim() +} + +/** + * Extracts frontmatter from markdown content + * @param {string} content - Raw markdown content + * @returns {Object} Frontmatter data + */ +function extractFrontmatter (content) { + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/) + if (!frontmatterMatch) return {} + + const frontmatter = {} + const lines = frontmatterMatch[1].split('\n') + for (const line of lines) { + const [ key, ...valueParts ] = line.split(':') + if (key && valueParts.length) { + frontmatter[key.trim()] = valueParts.join(':').trim() + } + } + return frontmatter +} + +/** + * Generates a URL from a relative file path + * @param {string} relativePath - Relative path to the markdown file + * @returns {string} Full URL + */ +function filePathToUrl (relativePath) { + const urlPath = relativePath + .replace(/\.md$/, '') + .replace(/:/g, '') // Remove colons from path (e.g., :tutorials) + return `${BASE_URL}/docs/en/${urlPath}` +} + +/** + * Processes a markdown file and extracts its content + * @param {string} filePath - Path to the markdown file + * @param {string} docsDir - Base docs directory + * @returns {string} Processed content with metadata + */ +function processMarkdownFile (filePath, docsDir) { + const content = readFileSync(filePath, 'utf-8') + const relativePath = relative(docsDir, filePath) + const frontmatter = extractFrontmatter(content) + const cleanContent = cleanMarkdownContent(content) + + const metadata = [ + frontmatter.title ? `# ${frontmatter.title}` : null, + `Source: ${filePathToUrl(relativePath)}`, + frontmatter.description ? `Description: ${frontmatter.description}` : null, + frontmatter.category ? `Category: ${frontmatter.category}` : null, + ] + .filter(Boolean) + .join('\n') + + return `${metadata}\n\n${cleanContent}\n` +} + +/** + * Recursively processes all markdown files in a directory + * @param {string} dir - Directory to process + * @param {string} docsDir - Base docs directory for relative paths + * @returns {string[]} Array of processed file contents + */ +function processDirectory (dir, docsDir) { + const results = [] + + if (!existsSync(dir)) { + console.error(`Directory does not exist: ${dir}`) + return results + } + + let files + try { + files = readdirSync(dir, { withFileTypes: true }) + } + catch (err) { + console.error(`Error reading directory ${dir}:`, err.message) + return results + } + + for (const file of files) { + if (config.excludes.includes(file.name)) continue + + const fullPath = join(dir, file.name) + + if (file.isDirectory()) { + results.push(...processDirectory(fullPath, docsDir)) + } + else if (file.name.endsWith('.md')) { + try { + results.push(processMarkdownFile(fullPath, docsDir)) + } + catch (err) { + console.error(`Error processing ${fullPath}:`, err.message) + } + } + } + + return results +} + +async function _handler () { + // Try local dev path first (src/views), then fall back to production symlink (node_modules/@architect/views) + let docsDir = join(__dirname, '..', '..', 'views', 'docs', 'en') + + if (!existsSync(docsDir)) { + docsDir = join(__dirname, 'node_modules', '@architect', 'views', 'docs', 'en') + } + + console.log('Attempting to read docs from:', docsDir) + + const header = `# Architect (arc.codes) - Complete Documentation + +> This is the complete documentation for Architect, a simple framework for building and delivering powerful Functional Web Apps (FWAs) on AWS. + +> For a high-level overview, see: ${BASE_URL}/llms.txt + +--- + +` + + const content = processDirectory(docsDir, docsDir) + const separator = '\n\n---\n\n' + const body = header + content.join(separator) + + return { + statusCode: 200, + headers: { + 'content-type': 'text/plain; charset=utf-8', + 'cache-control': 'max-age=86400', + }, + body, + } +} + +export const handler = arc.http.async(_handler) diff --git a/src/http/get-llms_txt/index.mjs b/src/http/get-llms_txt/index.mjs new file mode 100644 index 00000000..c9d455b1 --- /dev/null +++ b/src/http/get-llms_txt/index.mjs @@ -0,0 +1,84 @@ +import toc from '../../views/docs/table-of-contents.mjs' + +const BASE_URL = 'https://arc.codes' + +/** + * Generates a URL for a documentation page + * @param {string[]} pathParts - Path segments + * @returns {string} Full URL + */ +function docUrl (pathParts) { + const slug = pathParts + .map(part => part.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')) + .join('/') + return `${BASE_URL}/docs/en/${slug}` +} + +/** + * Recursively generates markdown links from the table of contents structure + * @param {Array} items - TOC items (strings or objects) + * @param {string[]} parentPath - Parent path segments + * @param {number} depth - Current nesting depth + * @returns {string} Markdown formatted links + */ +function generateLinks (items, parentPath = [], depth = 0) { + const indent = ' '.repeat(depth) + const lines = [] + + for (const item of items) { + if (typeof item === 'string') { + // Simple string item - it's a doc page + const path = [ ...parentPath, item ] + lines.push(`${indent}- [${item}](${docUrl(path)})`) + } + else if (typeof item === 'object' && !Array.isArray(item)) { + // Object with nested structure + for (const [ key, value ] of Object.entries(item)) { + if (Array.isArray(value)) { + // Category with sub-items + lines.push(`${indent}- ${key}`) + lines.push(generateLinks(value, [ ...parentPath, key ], depth + 1)) + } + } + } + } + + return lines.join('\n') +} + +export async function handler () { + const sections = [] + + sections.push('# Architect (arc.codes)') + sections.push('') + sections.push('> Architect is a simple framework for building and delivering powerful Functional Web Apps (FWAs) on AWS') + sections.push('') + sections.push('## Documentation') + sections.push('') + + for (const [ sectionName, items ] of Object.entries(toc)) { + sections.push(`### ${sectionName}`) + sections.push('') + sections.push(generateLinks(items, [ sectionName ])) + sections.push('') + } + + // Add quick links section + sections.push('## Quick Links') + sections.push('') + sections.push(`- [GitHub Repository](https://github.com/architect/architect)`) + sections.push(`- [Full Documentation for LLMs](${BASE_URL}/llms-full.txt)`) + sections.push(`- [Discord Community](https://discord.gg/y5A2eTsCRX)`) + sections.push('') + + const content = sections.join('\n') + + return { + statusCode: 200, + headers: { + 'content-type': 'text/plain; charset=utf-8', + 'cache-control': 'max-age=86400', + }, + body: content, + } +} diff --git a/src/views/modules/components/copy-markdown.mjs b/src/views/modules/components/copy-markdown.mjs new file mode 100644 index 00000000..d6cc1c61 --- /dev/null +++ b/src/views/modules/components/copy-markdown.mjs @@ -0,0 +1,26 @@ +export default function CopyMarkdown (state = {}) { + const { markdown } = state + + if (!markdown) return '' + + // Escape the markdown for safe embedding in a data attribute + const escapedMarkdown = markdown + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>') + + return ` + +` +} + diff --git a/src/views/modules/components/edit-link.mjs b/src/views/modules/components/edit-link.mjs index 370869c8..399b7353 100644 --- a/src/views/modules/components/edit-link.mjs +++ b/src/views/modules/components/edit-link.mjs @@ -1,8 +1,6 @@ export default function EditLink (state = {}) { const { editURL } = state return editURL ? ` - +Edit this doc on GitHub → ` : '' } diff --git a/src/views/modules/document/html.mjs b/src/views/modules/document/html.mjs index 9ce8b706..d17967a1 100644 --- a/src/views/modules/document/html.mjs +++ b/src/views/modules/document/html.mjs @@ -1,4 +1,5 @@ import Banner from '../components/banner.mjs' +import CopyMarkdown from '../components/copy-markdown.mjs' import DocumentOutline from '../components/document-outline.mjs' import EditLink from '../components/edit-link.mjs' import GoogleAnalytics from './ga.mjs' @@ -14,6 +15,7 @@ export default function HTML (props = {}) { html = '', editURL = '', lang = 'en', + markdown = '', scripts = '', slug = '', state = {}, @@ -75,9 +77,14 @@ ${Symbols} > ${title}
+
+ ${CopyMarkdown({ markdown })} +
${html} - ${EditLink({ editURL })} +
+ ${EditLink({ editURL })} +
${DocumentOutline(props)} diff --git a/test/backend/redirect-map-test.mjs b/test/backend/redirect-map-test.mjs index 8e110107..4b927c08 100644 --- a/test/backend/redirect-map-test.mjs +++ b/test/backend/redirect-map-test.mjs @@ -12,8 +12,8 @@ test('redirect map middleware', async t => { http: { method: 'GET', path: '/examples', - } - } + }, + }, }) const expectedResponse = { statusCode: 301, @@ -27,9 +27,9 @@ test('redirect map middleware', async t => { requestContext: { http: { method: 'get', - path: '/unmapped/path' - } - } + path: '/unmapped/path', + }, + }, }) assert.ok(!nonRedirectResponse, "Don't respond to unmapped path") @@ -37,9 +37,9 @@ test('redirect map middleware', async t => { requestContext: { http: { method: 'POST', - path: '/examples' - } - } + path: '/examples', + }, + }, }) assert.ok(!postResponse, "Don't respond to POST method") }) diff --git a/test/frontend/sidebar-test.mjs b/test/frontend/sidebar-test.mjs index 80ecb189..2c3c1146 100644 --- a/test/frontend/sidebar-test.mjs +++ b/test/frontend/sidebar-test.mjs @@ -31,14 +31,14 @@ function Item (state = {}) { ? Heading3({ children: Anchor({ children: child, - href: slugify(child) + href: slugify(child), }), - depth + depth, }) : '' } ${children} - ` + `, }) } @@ -69,26 +69,26 @@ const map = { item: Li, headings: [ Heading3, - Heading4 - ] + Heading4, + ], } test('render object to list', t => { const map = { list: Ul, - item: Li + item: Li, } const data = { 'one': [ 'a', 'b', - 'c' + 'c', ], 'two': [ 'd', 'e', - 'f' - ] + 'f', + ], } const expected = `
    @@ -118,7 +118,7 @@ test('render object to list', t => { test('render nested object to list', t => { const map = { list: Ul, - item: Li + item: Li, } const data = { 'label': [ @@ -126,17 +126,17 @@ test('render nested object to list', t => { 'one': [ 'a', 'b', - 'c' - ] + 'c', + ], }, { 'two': [ 'd', 'e', - 'f' - ] - } - ] + 'f', + ], + }, + ], } const expected = `
      @@ -177,21 +177,21 @@ test('render deeply nested object to list', t => { 'a': [ '1', '2', - '3' - ] + '3', + ], }, 'b', - 'c' - ] + 'c', + ], }, { 'two': [ 'd', 'e', - 'f' - ] - } - ] + 'f', + ], + }, + ], } const expected = `
        @@ -235,13 +235,13 @@ test('should use custom component map', t => { 'one': [ 'a', 'b', - 'c' + 'c', ], 'two': [ 'd', 'e', - 'f' - ] + 'f', + ], } const expected = `
          @@ -311,8 +311,8 @@ test('should use custom component map', t => { data, map: { list: Ul, - item: Item - } + item: Item, + }, }) assert.strictEqual(strip(actual), strip(expected), 'Should render object to custom list', actual) }) @@ -324,19 +324,19 @@ test('Should create correct href', t => { const href = slugify(path.join('/')) assert.ok(href, href) }, - list: function list () {} + list: function list () {}, } const data = { 'one & done': [ 'a', 'b', - 'c' + 'c', ], 'ok "maybe" one or two': [ 'd', 'e', - 'f' - ] + 'f', + ], } listFromObject({ data, map, path }) })