diff --git a/admin/app/services/docs_service.ts b/admin/app/services/docs_service.ts index ad1a4b0e..c5b34572 100644 --- a/admin/app/services/docs_service.ts +++ b/admin/app/services/docs_service.ts @@ -1,6 +1,7 @@ import Markdoc from '@markdoc/markdoc' import { streamToString } from '../../util/docs.js' import { getFile, getFileStatsIfExists, listDirectoryContentsRecursive } from '../utils/fs.js' +import { blockingFindings, markdocConfig } from '../utils/docs_markdoc.js' import path from 'path' import InternalServerErrorException from '#exceptions/internal_server_error_exception' import logger from '@adonisjs/core/services/logger' @@ -45,17 +46,15 @@ export class DocsService { parse(content: string) { try { const ast = Markdoc.parse(content) - const config = this.getConfig() - const errors = Markdoc.validate(ast, config) + const errors = Markdoc.validate(ast, markdocConfig) - // Filter out attribute-undefined errors which may be caused by emojis and special characters - const criticalErrors = errors.filter((e) => e.error.id !== 'attribute-undefined') + const criticalErrors = blockingFindings(errors) if (criticalErrors.length > 0) { logger.error('Markdoc validation errors:', errors.map((e) => JSON.stringify(e.error)).join(', ')) throw new Error('Markdoc validation failed') } - return Markdoc.transform(ast, config) + return Markdoc.transform(ast, markdocConfig) } catch (error) { logger.error('Error parsing Markdoc content:', error) throw new InternalServerErrorException(`Error parsing content: ${(error as Error).message}`) @@ -109,100 +108,4 @@ export class DocsService { const titleCased = cleaned.replace(/\b\w/g, (char) => char.toUpperCase()) return titleCased.charAt(0).toUpperCase() + titleCased.slice(1) } - - private getConfig() { - return { - tags: { - callout: { - render: 'Callout', - attributes: { - type: { - type: String, - default: 'info', - matches: ['info', 'warning', 'error', 'success'], - }, - title: { - type: String, - }, - }, - }, - }, - nodes: { - heading: { - render: 'Heading', - attributes: { - level: { type: Number, required: true }, - id: { type: String }, - }, - }, - list: { - render: 'List', - attributes: { - ordered: { type: Boolean }, - start: { type: Number }, - }, - }, - list_item: { - render: 'ListItem', - attributes: { - marker: { type: String }, - className: { type: String }, - class: { type: String } - } - }, - table: { - render: 'Table', - }, - thead: { - render: 'TableHead', - }, - tbody: { - render: 'TableBody', - }, - tr: { - render: 'TableRow', - }, - th: { - render: 'TableHeader', - }, - td: { - render: 'TableCell', - }, - paragraph: { - render: 'Paragraph', - }, - image: { - render: 'Image', - attributes: { - src: { type: String, required: true }, - alt: { type: String }, - title: { type: String }, - }, - }, - link: { - render: 'Link', - attributes: { - href: { type: String, required: true }, - title: { type: String }, - }, - }, - fence: { - render: 'CodeBlock', - attributes: { - content: { type: String }, - language: { type: String }, - }, - }, - code: { - render: 'InlineCode', - attributes: { - content: { type: String }, - }, - }, - hr: { - render: 'HorizontalRule', - }, - }, - } - } } diff --git a/admin/app/utils/docs_markdoc.ts b/admin/app/utils/docs_markdoc.ts new file mode 100644 index 00000000..d8218f12 --- /dev/null +++ b/admin/app/utils/docs_markdoc.ts @@ -0,0 +1,112 @@ +import type { ValidateError } from '@markdoc/markdoc' + +/** Markdoc schema for the bundled docs: the custom tags and the node renderers. */ +export const markdocConfig = { + tags: { + callout: { + render: 'Callout', + attributes: { + type: { + type: String, + default: 'info', + matches: ['info', 'warning', 'error', 'success'], + }, + title: { + type: String, + }, + }, + }, + }, + nodes: { + heading: { + render: 'Heading', + attributes: { + level: { type: Number, required: true }, + id: { type: String }, + }, + }, + list: { + render: 'List', + attributes: { + ordered: { type: Boolean }, + start: { type: Number }, + }, + }, + list_item: { + render: 'ListItem', + attributes: { + marker: { type: String }, + className: { type: String }, + class: { type: String }, + }, + }, + table: { + render: 'Table', + }, + thead: { + render: 'TableHead', + }, + tbody: { + render: 'TableBody', + }, + tr: { + render: 'TableRow', + }, + th: { + render: 'TableHeader', + }, + td: { + render: 'TableCell', + }, + paragraph: { + render: 'Paragraph', + }, + image: { + render: 'Image', + attributes: { + src: { type: String, required: true }, + alt: { type: String }, + title: { type: String }, + }, + }, + link: { + render: 'Link', + attributes: { + href: { type: String, required: true }, + title: { type: String }, + }, + }, + fence: { + render: 'CodeBlock', + attributes: { + content: { type: String }, + language: { type: String }, + }, + }, + code: { + render: 'InlineCode', + attributes: { + content: { type: String }, + }, + }, + hr: { + render: 'HorizontalRule', + }, + }, +} + +/** + * The validation findings that should stop a doc from rendering. + * + * Warnings are not among them. Markdoc warns about a **bold** span hard-wrapped + * across lines, and transform() renders it fine, so failing on warnings turned a + * line wrap into a 500 for the whole page (#1427). attribute-undefined is + * skipped as well, since emojis and special characters can raise it. + */ +export function blockingFindings(errors: ValidateError[]): ValidateError[] { + return errors.filter( + (e) => + e.error.id !== 'attribute-undefined' && + (e.error.level === 'error' || e.error.level === 'critical') + ) +} diff --git a/admin/docs/faq.md b/admin/docs/faq.md index f94fc842..4eacd491 100644 --- a/admin/docs/faq.md +++ b/admin/docs/faq.md @@ -38,9 +38,8 @@ defensive one. **The AI is the variable, and it is the model rather than NOMAD.** Ollama idles at roughly 1.2GB, and a model needs about its download size resident while it answers, -so an 8B model at ~4.6GB wants about that much on top. **8GB is workable for a small -model, 16GB is comfortable, and 32GB is the recommendation if you want to run larger -ones.** +so an 8B model at ~4.6GB wants about that much on top. +**8GB is workable for a small model, 16GB is comfortable, and 32GB is the recommendation if you want to run larger ones.** Where that memory comes from depends on your hardware. With a discrete GPU the model loads into VRAM and never touches system RAM, so VRAM is the number that limits which diff --git a/admin/tests/unit/docs_markdoc.spec.ts b/admin/tests/unit/docs_markdoc.spec.ts new file mode 100644 index 00000000..f5de6645 --- /dev/null +++ b/admin/tests/unit/docs_markdoc.spec.ts @@ -0,0 +1,35 @@ +import * as assert from 'node:assert/strict' +import { readdir, readFile } from 'node:fs/promises' +import { test } from 'node:test' + +import Markdoc from '@markdoc/markdoc' + +import { blockingFindings, markdocConfig } from '../../app/utils/docs_markdoc.js' + +function findings(content: string) { + return blockingFindings(Markdoc.validate(Markdoc.parse(content), markdocConfig)) +} + +test('a bold span hard-wrapped across lines does not block rendering (#1427)', () => { + const wrapped = '**8GB is workable for a small\nmodel, 16GB is comfortable.**\n' + assert.ok(Markdoc.validate(Markdoc.parse(wrapped), markdocConfig).length > 0) + assert.deepEqual(findings(wrapped), []) +}) + +test('an unknown tag still blocks rendering', () => { + assert.equal(findings('{% nope %}\ntext\n{% /nope %}\n').length, 1) +}) + +const docsDir = new URL('../../docs/', import.meta.url) +const entries = await readdir(docsDir) +const docs = entries.filter((name) => name.endsWith('.md')) + +for (const name of docs) { + test(`docs/${name} passes DocsService validation`, async () => { + const content = await readFile(new URL(name, docsDir), 'utf8') + assert.deepEqual( + findings(content).map((f) => `line ${f.lines[0] + 1}: ${f.error.message}`), + [] + ) + }) +}