diff --git a/content/talks/jbp-allison-duettmann/index.md b/content/talks/jbp-allison-duettmann/index.md index afbf6f1f..099ec3f6 100644 --- a/content/talks/jbp-allison-duettmann/index.md +++ b/content/talks/jbp-allison-duettmann/index.md @@ -6,7 +6,7 @@ venue_url: "https://www.youtube.com/@JuanBenetPodcast" venue_location: "Podcast" authors: - juan-benet - - allison-duettmann + - "Allison Duettmann" areas: - neurotech abstract: "Juan Benet speaks with Allison Duettmann, CEO of the Foresight Institute, about why cryonics and mind emulation could mean we don't have to die, how whole-brain emulation and connectomics could keep humans compatible with superintelligent AI, how nanotechnology could rebuild the physical world, and the case for Existential Hope — imagining flourishing futures as a precondition for building them." diff --git a/content/talks/jbp-konrad-kording/index.md b/content/talks/jbp-konrad-kording/index.md index a7ab6f00..6b19d4a1 100644 --- a/content/talks/jbp-konrad-kording/index.md +++ b/content/talks/jbp-konrad-kording/index.md @@ -6,7 +6,7 @@ venue_url: "https://www.youtube.com/@JuanBenetPodcast" venue_location: "Podcast" authors: - juan-benet - - konrad-kording + - "Konrad Kording" areas: - neurotech abstract: "Juan Benet speaks with Konrad Kording of the University of Pennsylvania about the surprising computational power of a single neuron, reading the brain's wiring down to molecular detail, and building compilers and simulations as a path toward understanding — and ultimately simulating — the brain." diff --git a/content/talks/jbp-tom-oxley/index.md b/content/talks/jbp-tom-oxley/index.md index 431399b9..b6217b44 100644 --- a/content/talks/jbp-tom-oxley/index.md +++ b/content/talks/jbp-tom-oxley/index.md @@ -6,7 +6,7 @@ venue_url: "https://www.youtube.com/@JuanBenetPodcast" venue_location: "Podcast" authors: - juan-benet - - tom-oxley + - "Tom Oxley" areas: - neurotech abstract: "Juan Benet speaks with Tom Oxley, co-founder and CEO of Synchron, about the Stentrode — a brain-computer interface that reaches the motor cortex through a blood vessel without opening the skull — restoring independence to people with motor impairment and the path toward higher-channel-count neural interfaces." diff --git a/docs/ai-context.md b/docs/ai-context.md new file mode 100644 index 00000000..1c6db540 --- /dev/null +++ b/docs/ai-context.md @@ -0,0 +1,45 @@ +# Public AI context + +This is additive read-only access, not a chatbot, crawler, embeddings service or permission grant. The human entry point is `/ai/`; the footer and HTML `rel="describedby"` link lead to it or `/llms.txt`. + +## Content contract + +`content/` → existing `scripts/build-content.mjs` mappers → checked-in generated JSON → `src/lib/content.ts` → `src/lib/ai-content.ts` → text and JSON route handlers. There is no second content catalog, runtime Markdown parser, arbitrary-file route, CMS dump or URL-fetching API. Build-time `readable-markdown.mjs` uses the already-installed jsdom package to turn the same native HTML body into readable Markdown. Source folder/file IDs are preserved exactly, including historical mixed-case IDs. + +Covered: + +- Native blog articles: source text and attribution. CSS/scripts and interactive widgets are omitted; images and diagrams use supplied descriptions. Existing disclosures remain part of the text. +- External blog entries: the local summary and byline only. `canonicalKind: external` and `canonicalUrl` identify the original article; `sourceUrl` identifies the local metadata page. External full text is not fetched or republished. +- Publications: abstracts, named authors, dates, venue, publication types, DOI/PDF/source links. No full-paper claim. +- Talks: source summaries, venue metadata and available recording links. No transcripts, generated or otherwise. +- Tutorials: top-level text when present, otherwise metadata. Nested tutorial lessons are not included. +- Authors: exact display names and profile metadata. Slugs resolve exactly; exact unique display names can also resolve. Unmatched strings remain unchanged and unlinked. No fuzzy identity matching or inferred affiliations. +- Four public focus areas: the shared `FOCUS_AREA_DESCRIPTIONS` strings and navigation titles, plus current page links. No full overview-body export. + +Not covered: landing/About body copy, live ATProto posts or edits, interactive dashboards/data, nested lessons, unpublished material, non-discoverable routes, repository documentation/source, or operational interfaces. This is an explicit allowlist, not a recursive site dump. A short topic list reflects source tagging, not the amount of research in that field. + +### Snapshot versus live pages + +The generic area template currently renders repository content, while the special Economies & Governance page, landing page and About page also read live indexer sections with hardcoded fallbacks. They do not share one full-page loader. The export deliberately does not serialize seed files or stale Markdown overview bodies as live copy: it uses the same short shared descriptors and links readers to the rendered pages. This keeps static exports reproducible and avoids importing unrestricted live CMS records. The scope appears in the guide, index, complete text, focused resources and record coverage fields. + +Dates are source publication dates, not export or last-modified timestamps. Undated records and descriptor-only area records have null dates. Coverage reports the actual included counts and source date range. No new license is asserted; original terms and exceptions remain in force. + +## HTTP interface (schema 1.0) + +- `GET /llms.txt`: compact plain-text entry index. +- `GET /llms-full.txt`: downloadable plain-text covered context. +- `GET /ai/index.json`: metadata for all covered records, counts, dates, limits and resource links. Bodies are excluded from this index. +- `GET /api/ai/search/?q=connectome&kind=publication&limit=5`: bounded JSON search. +- `GET /ai/records/{kind}/{slug}/`: a covered record as JSON. +- `GET /ai/markdown/{kind}/{slug}/`: the same record as `text/markdown`. +- `GET /ai/topics/{area}/`: descriptor and focused resource links as `text/markdown`. + +Every route works with plain HTTP GET. Internal page/dynamic-resource URLs require trailing slashes; file-like `/llms.txt`, `/llms-full.txt` and `/ai/index.json` do not. GET is the only defined method (Next supplies HEAD/OPTIONS and rejects mutation methods). No credentialed CORS or runtime upstream requests are added. Success responses are public-cacheable for 300 seconds with nosniff; errors use no-store. Static files are generated with the deployment, while search/details reapply the visibility gate on the same repository snapshot. + +Search accepts only `q`, `kind`, `area`, `limit`, `offset`, once each. q is at most 200 characters with no control characters. kind is area/author/blog/publication/talk/tutorial. area is a covered focus-area slug. limit defaults to 10 and must be an integer 1–50; offset defaults to 0 and must be an integer 0–10000. Matching is case-insensitive AND substrings across title, summary, covered body and author names; ordering is stable ID ascending, never a relevance or quality score. `total` is the number of matches, `nextOffset` is null at the end. Valid searches without matches return 200 with an empty list. Unknown, malformed or duplicate parameters return 400. Unknown or denied detail/topic resources return an indistinguishable 404. + +## Visibility and verification + +Every shared mapper retains normalized visibility (version, denied flag, source not-before date). Hidden, draft, unlisted, preview, unaffiliated, private, noindex/robots metadata, nonpublic statuses, future dates and malformed dates are denied. Unsupported visibility versions fail closed. Only six explicit collections and four approved area descriptors can enter the export. Reserved operational paths, nonpublic paths, credential-bearing URLs and repository-document links are not export sources. Discovery and direct details use the same filtered view; guessing an ID does not bypass it. Existing HTML publishing behavior is not changed by the AI access layer. + +`npm test` includes the AI behavioral suite. It exercises native-source equality, collection coverage, exact bylines/canonicals, source-ID preservation, deterministic real mapper builds with synthetic hidden frontmatter, hidden data at discovery/detail boundaries, query bounds/pagination/errors, text MIME/headers, route wiring and server-rendered guide discovery. No browser checks are implied by these tests. Run `node_modules/.bin/tsc --noEmit` and the normal production build as well. Install dependencies with pnpm 10 and the frozen lockfile; no dependency or lockfile change is needed for this feature. diff --git a/docs/ai-context/ai-1440-full.png b/docs/ai-context/ai-1440-full.png new file mode 100644 index 00000000..13a4d850 Binary files /dev/null and b/docs/ai-context/ai-1440-full.png differ diff --git a/docs/ai-context/ai-1440.png b/docs/ai-context/ai-1440.png new file mode 100644 index 00000000..3a0746fa Binary files /dev/null and b/docs/ai-context/ai-1440.png differ diff --git a/docs/ai-context/ai-390-full.png b/docs/ai-context/ai-390-full.png new file mode 100644 index 00000000..8b23baa0 Binary files /dev/null and b/docs/ai-context/ai-390-full.png differ diff --git a/docs/ai-context/ai-390.png b/docs/ai-context/ai-390.png new file mode 100644 index 00000000..a5944e06 Binary files /dev/null and b/docs/ai-context/ai-390.png differ diff --git a/docs/ai-context/verification.md b/docs/ai-context/verification.md new file mode 100644 index 00000000..899ded2f --- /dev/null +++ b/docs/ai-context/verification.md @@ -0,0 +1,25 @@ +# AI context — local verification + +Screenshot and first served-sweep revision: `0651f818dfc2f69b97859095b038f90c68870730`, tested 2026-09-11. Subsequent shared-gate hardening covers metadata references and slash/host boundaries; the complete current-corpus Markdown export was verified byte-for-byte identical to the downloaded capture. Guide/component source is unchanged. The final exact SHA is recorded in the PR review. + +## Executed + +- pnpm 10 frozen installation succeeded; dependency declarations and lockfiles unchanged. +- Final default suite: 128 tests passed, zero failures/skips. Generated content source parity, mixed-case IDs, exact bylines, visibility gates, direct hidden details and query bounds are covered. Metadata disclosure and technical read/write prose each have verified RED→GREEN regressions; the metadata fixture exercises index, search, JSON detail, Markdown detail and the full export. +- Ordinary API/preview/edit prose was observed failing before its correction. A `/lab/` reference in a text field was also observed leaking before its correction; private absolute/relative references now deny while an external public lab URL remains eligible. +- Typecheck passed. Production build passed, with 322 static pages generated. +- Real local production server: 818 HTTP checks passed (794 successes, six expected 400s, twelve expected 404s and six expected 405s). Every one of the 261 exported records had its JSON detail, Markdown and source page fetched. All four topic routes worked. +- Separate parent probes: 13 entrypoint/link checks plus 18 pagination/source-parity/negative-input checks passed. Source IDs match all 261 eligible records across six collections. +- Real Chrome: desktop 1440px, mobile 390px and narrow 320px document/heading bounds passed. Copy prompt and footer-to-guide navigation worked. Clicking the actual download produced `plrd-context.txt`, 254,802 bytes. + +## Screenshot provenance + +The four PNGs are actual local Chrome captures of `/ai/` on the runtime revision above, not hosted previews. No authentication fixture or production setting was changed. Viewport captures: `ai-1440.png`, `ai-390.png`; full-page companions use `-full`. + +## Scope and limits + +This is a build-time snapshot, not a live CMS mirror or whole-site dump: native blog/tutorial text, publication abstracts/metadata, talk summaries/recording links, author metadata and shared area descriptors. Live overview copy, ATProto posts, dashboards and nested tutorial lessons remain outside coverage. Canonical links preserve attribution; no new rights or guaranteed AI discovery are claimed. + +The build reported existing external-data path warnings (indexer query failure, closed-round fallback and oversized uncacheable payloads) but exited 0. Those paths are unchanged; a separate base-only build was not repeated, so no independently verified baseline diagnosis is claimed. Third-party destination contents were not re-researched. + +Independent exact-revision review is a separate required handoff gate, recorded in the PR. No merge or production deployment is part of these local checks. diff --git a/package.json b/package.json index 6d23f956..928bb4a9 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build": "node scripts/build-content.mjs && next build", "start": "next start", "generate-jwk": "node scripts/generate-jwk.js", - "test": "node --test scripts/velocity/*.test.mjs scripts/neuro-content.test.mjs scripts/neuro-publication.test.mjs scripts/blog-metadata.test.mjs" + "test": "node --test scripts/velocity/*.test.mjs scripts/neuro-content.test.mjs scripts/neuro-publication.test.mjs scripts/blog-metadata.test.mjs scripts/ai-*.test.mjs" }, "dependencies": { "@atproto/api": "^0.18.17", diff --git a/scripts/ai-access.test.mjs b/scripts/ai-access.test.mjs new file mode 100644 index 00000000..321cef9d --- /dev/null +++ b/scripts/ai-access.test.mjs @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { existsSync } from 'node:fs' +import { source } from './velocity/test-source-loader.mjs' + +const request = (query = '') => new Request(`https://www.plrd.org/api/ai/search/${query}`) +test('read-only access returns deterministic bounded search and canonical detail', async () => { + assert.ok(existsSync('src/lib/ai-access.ts'), 'public HTTP access is required') + const { createAiAccess } = source('lib/ai-access.ts') + const api = createAiAccess() + const response = api.search(request('?q=connectome&kind=publication&limit=2')) + assert.equal(response.status, 200) + assert.match(response.headers.get('content-type'), /^application\/json/) + assert.equal(response.headers.get('x-content-type-options'), 'nosniff') + const result = await response.json() + assert.equal(result.schemaVersion, '1.0') + assert.ok(result.total > 0) + assert.ok(result.results.length <= 2) + assert.deepEqual(result, await api.search(request('?q=connectome&kind=publication&limit=2')).json()) + const r = result.results[0] + const detail = await api.detail(r.kind, r.slug).json() + assert.equal(detail.record.canonicalUrl, r.canonicalUrl) + assert.equal(detail.record.visibility, undefined) + assert.ok(r.markdownUrl.endsWith('/')) + assert.ok(!('body' in r)) + assert.equal((await api.search(request('?q=zzzznomatchzzzz')).json()).total, 0) + for (const query of ['?limit=0', '?limit=51', '?limit=1.5', '?offset=-1', '?offset=10001', '?kind=other', '?q=a&q=b', '?url=http://127.0.0.1', '?q='+ 'a'.repeat(201), '?area=bogus', '?offset=01']) { + assert.equal(api.search(request(query)).status, 400, query) + } + assert.equal(api.detail('blog', 'does-not-exist').status, 404) + assert.equal(api.detail('admin', 'account').status, 404) + assert.equal(api.detail('blog', '../about').status, 404) + const page1 = await api.search(request('?limit=2')).json() + const page2 = await api.search(request('?limit=2&offset=2')).json() + assert.equal(page1.nextOffset, 2) + assert.ok(page1.results.every(a => !page2.results.some(b => b.id === a.id))) + const end = await api.search(request('?offset=10000')).json() + assert.deepEqual(end.results, []) + assert.equal(end.nextOffset, null) +}) diff --git a/scripts/ai-content.test.mjs b/scripts/ai-content.test.mjs new file mode 100644 index 00000000..a5465335 --- /dev/null +++ b/scripts/ai-content.test.mjs @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { existsSync } from 'node:fs' +import { source } from './velocity/test-source-loader.mjs' + +const content = source('lib/content.ts') + +test('public context maps native bodies, exact authors, external canonicals and shared area descriptors', () => { + assert.ok(existsSync('src/lib/ai-content.ts'), 'typed public context layer is required') + const { aiRecords } = source('lib/ai-content.ts') + const post = aiRecords.find(r => r.id === 'blog/neurotech-frontier-human-flourishing') + assert.ok(post) + assert.equal(post.body, content.blogPosts.find(p => p.slug === post.slug).markdown) + assert.deepEqual(post.authors.map(a => a.name), ['Sean Escola', 'David A. Markowitz']) + assert.ok(post.authors.every(a => a.url.endsWith('/'))) + const external = aiRecords.find(r => r.kind === 'blog' && r.canonicalKind === 'external') + assert.ok(external) + assert.equal(external.body, '') + assert.equal(external.canonicalUrl, content.blogPosts.find(p => p.slug === external.slug).external_url) + const pub = aiRecords.find(r => r.id === 'publication/how-to-obtain-complete-human-connectome') + assert.equal(pub.summary, content.publications.find(p => p.slug === pub.slug).abstract) + assert.ok(pub.sources.some(s => s.label === 'DOI')) + const talk = aiRecords.find(r => r.slug === 'jbp-adam-marblestone') + assert.equal(talk.body, '') + assert.ok(talk.sources.some(s => s.url === 'https://www.youtube.com/watch?v=X0B_GWTuEFo')) + for (const area of aiRecords.filter(r => r.kind === 'area')) { + assert.equal(area.body, '') + assert.equal(area.date, null) + assert.equal(area.summary, source('lib/focus-area-descriptions.ts').FOCUS_AREA_DESCRIPTIONS[area.slug]) + } + for (const t of content.tutorials) assert.equal(typeof t.markdown, 'string') + for (const items of [content.blogPosts.filter(p => p.unlisted)]) { + for (const hidden of items) assert.ok(!aiRecords.some(r => r.slug === hidden.slug)) + } +}) + +test('coverage includes every public source record without lowercasing historical slugs', () => { + const { aiRecords } = source('lib/ai-content.ts') + for (const [key, kind] of [['publications','publication'], ['authors','author'], ['talks','talk'], ['tutorials','tutorial'], ['areas','area'], ['blogPosts','blog']]) { + const expected = content[key].filter(r => !r.visibility.denied && (!r.visibility.notBefore || Date.parse(r.visibility.notBefore) <= Date.now())).map(r => r.slug).sort() + assert.deepEqual(aiRecords.filter(r => r.kind === kind).map(r => r.slug).sort(), expected) + } + for (const slug of ['Azouvi2022', 'Azouvi2022a']) assert.ok(aiRecords.some(r => r.canonicalUrl.endsWith(`/publications/${slug}/`))) +}) + +test('visibility survives every shared mapper; unlisted content remains denied', () => { + for (const items of [content.blogPosts, content.publications, content.talks, content.tutorials, content.authors, content.areas]) { + for (const item of items) { + assert.equal(typeof item.visibility, 'object', `${item.slug}: missing visibility metadata`) + assert.equal(item.visibility.version, 1) + } + } + const hidden = content.blogPosts.filter(p => p.unlisted) + assert.ok(hidden.length) + for (const p of hidden) assert.equal(p.visibility.denied, true) +}) + +test('shared mapper retains the native article as readable Markdown, including source links and disclosure', () => { + const post = content.blogPosts.find(p => p.slug === 'neurotech-frontier-human-flourishing') + assert.equal(typeof post.markdown, 'string', 'shared blog mapper must retain a readable body') + assert.match(post.markdown, /Neural Augmentation/) + assert.match(post.markdown, /https:\/\/paradromics.com\/news\//) + assert.match(post.markdown, /financial interests in some of the companies mentioned/) + assert.doesNotMatch(post.markdown, / { + const original = [...content.aiRecords] + try { + for (const [collection, field, kind] of [['talks', 'venue_location', 'talk'], ['publications', 'venue', 'publication']]) { + const seed = content.aiContentSources[collection][0] + for (const hidden of [false, true]) { + const text = hidden ? 'Venue notes: https://www.plrd.org/lab/PRIVATE_METADATA_SENTINEL' : 'Public venue notes' + const input = { ...content.aiContentSources, [collection]: [{ ...seed, [field]: text }] } + const records = content.buildAiRecords(input) + content.aiRecords.splice(0, content.aiRecords.length, ...records) + const api = createAiAccess() + const detail = api.detail(kind, seed.slug) + const md = await markdown.GET(new Request('https://www.plrd.org/'), { params: Promise.resolve({ kind, slug: seed.slug }) }) + assert.equal(detail.status, hidden ? 404 : 200, `${collection}: JSON detail`) + assert.equal(md.status, hidden ? 404 : 200, `${collection}: Markdown detail`) + const bodies = await Promise.all([ + api.index().text(), api.search(new Request('https://www.plrd.org/api/ai/search/')).text(), + detail.text(), md.text(), full.GET().text(), + ]) + for (const body of bodies) assert.doesNotMatch(body, /PRIVATE_METADATA_SENTINEL/) + if (!hidden) for (const body of [bodies[2], bodies[3], bodies[4]]) assert.ok(body.includes(text)) + } + } + } finally { content.aiRecords.splice(0, content.aiRecords.length, ...original) } +}) + +test('technical slash prose and external public API links remain eligible', async () => { + const seed = content.aiContentSources.blogPosts.find(r => !r.external_url && r.markdown) + for (const body of ['We analyze read/write performance.', 'Read https://example.org/api/docs/ for public documentation.', 'Read https://example.org/preview/ for public research.']) { + const records = content.buildAiRecords({ ...content.aiContentSources, blogPosts: [{ ...seed, markdown: body }] }) + const detail = createAiAccess(records).detail('blog', seed.slug) + assert.equal(detail.status, 200, body) + assert.equal((await detail.json()).record.body, body) + } + const external = content.buildAiRecords({ ...content.aiContentSources, blogPosts: [{ ...seed, external_url: 'https://example.org/api/docs/' }] }) + assert.equal(createAiAccess(external).detail('blog', seed.slug).status, 200) + for (const body of ['See /api/.', 'See /write/.', 'See /lab/.', 'See https://plrd.org/%6cab/.', 'See https://www.plresearch.org/blog/example/?preview=true.']) { + const records = content.buildAiRecords({ ...content.aiContentSources, blogPosts: [{ ...seed, markdown: body }] }) + assert.equal(createAiAccess(records).detail('blog', seed.slug).status, 404, body) + } +}) diff --git a/scripts/ai-pipeline.test.mjs b/scripts/ai-pipeline.test.mjs new file mode 100644 index 00000000..9d53d5db --- /dev/null +++ b/scripts/ai-pipeline.test.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs' +import path from 'node:path' +import { execFileSync } from 'node:child_process' +import { source } from './velocity/test-source-loader.mjs' +import { readableMarkdown } from './readable-markdown.mjs' + +const { buildAiRecords } = source('lib/ai-content.ts') + +test('real build pipeline preserves source IDs and rejects hidden frontmatter before public serialization', () => { + mkdirSync('.next', { recursive: true }) + const sandbox = mkdtempSync(path.resolve('.next/ai-fixtures-')) + const flags = [{ unlisted: true }, { draft: true }, { preview: true }, { noindex: true }, { unaffiliated: true }, { robots: ['noindex'] }, { status: 'draft' }, { date: '2999-01-01' }] + try { + mkdirSync(path.join(sandbox, 'public')) + for (const collection of ['publications', 'talks', 'blog', 'tutorials', 'authors', 'areas']) { + mkdirSync(path.join(sandbox, 'content', collection), { recursive: true }) + const entries = [ + { slug: 'source-id', data: { slug: 'must-not-override-source' }, body: 'Real native body with [source](https://example.org/paper).' }, + ...flags.map((flag, i) => ({ slug: `hidden-${i}`, data: flag, body: 'SYNTHETIC_HIDDEN_BODY' })), + ] + for (const item of entries) { + const data = { title: item.slug, name: item.slug, date: '2020-01-01', summary: 'Fixture summary', abstract: 'Fixture abstract', internalSecret: 'DO_NOT_COPY_RAW_KEYS', ...item.data } + writeFileSync(path.join(sandbox, 'content', collection, `${item.slug}.md`), `---\n${JSON.stringify(data)}\n---\n${item.body}\n`) + } + } + const build = () => execFileSync(process.execPath, [path.resolve('scripts/build-content.mjs')], { cwd: sandbox, timeout: 30000, stdio: 'pipe' }) + build() + const read = name => JSON.parse(readFileSync(path.join(sandbox, `src/data/generated/${name}.json`), 'utf8')) + const input = { publications: read('publications'), talks: read('talks'), blogPosts: read('blog'), tutorials: read('tutorials'), authors: read('authors'), areas: read('areas') } + for (const [key, records] of Object.entries(input)) { + assert.ok(records.some(r => r.slug === 'source-id'), `${key}: frontmatter must not override source ID`) + assert.ok(!JSON.stringify(records).includes('DO_NOT_COPY_RAW_KEYS')) + } + for (const item of [...input.blogPosts, ...input.tutorials].filter(r => r.slug.startsWith('hidden-'))) { + assert.equal(item.markdown, '', 'do not add readable copies of withheld bodies to generated JSON') + } + const records = buildAiRecords(input) + assert.equal(records.length, 5, 'five public records; unrecognized focus areas are never auto-published') + assert.ok(records.every(r => r.slug === 'source-id')) + assert.ok(!JSON.stringify(records).includes('SYNTHETIC_HIDDEN_BODY')) + const before = JSON.stringify(input) + build() + assert.equal(JSON.stringify({ publications: read('publications'), talks: read('talks'), blogPosts: read('blog'), tutorials: read('tutorials'), authors: read('authors'), areas: read('areas') }), before) + } finally { rmSync(sandbox, { recursive: true, force: true }) } +}) + +test('Markdown conversion preserves readable content, code and canonical internal slashes without executable markup', () => { + const body = readableMarkdown('

Heading

A claim & source.

const x = 1;\nprint(x)
', 'https://www.plrd.org/blog/example/') + assert.match(body, /## Heading/) + assert.match(body, /\*\*claim\*\*/) + assert.match(body, /https:\/\/www.plrd.org\/about\//) + assert.match(body, /const x = 1;\nprint\(x\)/) + assert.match(body, /Source diagram description/) + assert.doesNotMatch(body, /SECRET|HIDDEN|STYLE| { + for (const route of routes) assert.ok(existsSync(`src/${route}`), `missing ${route}`) + for (const route of routes) { + const mod = source(route) + assert.equal(typeof mod.GET, 'function') + for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) assert.equal(mod[method], undefined) + } + const { aiRecords } = source('lib/ai-content.ts') + const index = source(routes[0]).GET() + assert.match(index.headers.get('content-type'), /^text\/plain; charset=utf-8$/) + const text = await index.text() + assert.ok(text.length < 6000) + assert.match(text, /https:\/\/www.plrd.org\/ai\/index.json/) + const full = source(routes[1]).GET() + assert.match(full.headers.get('content-disposition'), /attachment/) + const context = await full.text() + assert.match(context, /David A. Markowitz/) + assert.match(context, /No transcript provided/) + assert.doesNotMatch(context, /class=| r.kind === 'blog' && r.canonicalKind === 'native' && r.areas.includes('neurotech')) + const request = new Request('https://www.plrd.org/ai/') + const params = { params: Promise.resolve({ kind: first.kind, slug: first.slug }) } + const markdown = await source(routes[5]).GET(request, params) + assert.match(markdown.headers.get('content-type'), /^text\/markdown/) + assert.ok((await markdown.text()).includes(first.body)) + const detail = await source(routes[4]).GET(request, params) + assert.equal((await detail.json()).record.id, first.id) + const topic = await source(routes[6]).GET(request, { params: Promise.resolve({ slug: 'neurotech' }) }) + assert.equal(topic.status, 200) + assert.ok((await topic.text()).includes(first.canonicalUrl)) + for (const route of [routes[4], routes[5]]) { + for (const slug of ['does-not-exist', 'preview-synthetic', 'open-lab', '../about']) { + assert.equal((await source(route).GET(request, { params: Promise.resolve({ kind: 'blog', slug }) })).status, 404) + } + } + assert.equal((await source(routes[6]).GET(request, { params: Promise.resolve({ slug: 'bogus' }) })).status, 404) +}) + +test('guide is additive, has accessible copyable prompt, source/scope links and HTML discovery', async () => { + assert.ok(existsSync('src/app/ai/page.tsx'), 'human guide is required') + const React = await import('react') + const { renderToStaticMarkup } = await import('react-dom/server') + const html = renderToStaticMarkup(React.createElement(source('app/ai/page.tsx').default)) + for (const text of ['llms.txt', 'llms-full.txt', 'ai/index.json', 'api/ai/search/', 'snapshot', 'Copy prompt']) assert.ok(html.includes(text), text) + assert.ok(readFileSync('src/components/SiteFooter.tsx', 'utf8').includes('href="/ai/"')) + assert.match(readFileSync('src/app/layout.tsx', 'utf8'), /]+href="\/llms.txt"/) +}) diff --git a/scripts/ai-visibility.test.mjs b/scripts/ai-visibility.test.mjs new file mode 100644 index 00000000..03e1635d --- /dev/null +++ b/scripts/ai-visibility.test.mjs @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { contentVisibility } from './content-visibility.mjs' +import { source } from './velocity/test-source-loader.mjs' + +const { aiRecords, buildAiRecords, aiContentSources, resolveAiAuthor } = source('lib/ai-content.ts') +const { createAiAccess } = source('lib/ai-access.ts') +const flags = [ + { unlisted: true }, { draft: true }, { preview: true }, { noindex: true }, + { unaffiliated: true }, { private: true }, { hidden: true }, { visibility: 'private' }, + { status: 'draft' }, { published: false }, { public: false }, { listed: false }, + { robots: 'noindex, nofollow' }, { robots: { index: false } }, { robots: { googleBot: { index: false } } }, + { robots: ['noindex'] }, { date: '2999-01-01' }, { publishDate: '2999-01-01' }, { date: 'invalid-date' }, +] + +test('synthetic hidden records fail closed in every collection, discovery and direct detail', async () => { + const seed = aiRecords.find(r => r.kind === 'blog') + for (const [i, flag] of flags.entries()) { + const visibility = contentVisibility(flag) + const slug = `synthetic-hidden-${i}` + const url = `https://www.plrd.org/blog/${slug}/` + const fixture = { ...seed, id: `blog/${slug}`, slug, sourceUrl: url, canonicalUrl: url, title: 'DO NOT EXPORT SYNTHETIC SENTINEL', visibility } + assert.equal(createAiAccess([{ ...fixture, visibility: contentVisibility({}) }]).detail('blog', slug).status, 200, 'control must be eligible without the hidden flag') + const api = createAiAccess([...aiRecords, fixture]) + assert.equal(api.detail('blog', slug).status, 404, JSON.stringify(flag)) + assert.ok(!(await api.index().text()).includes('SYNTHETIC SENTINEL'), JSON.stringify(flag)) + assert.equal((await api.search(new Request('https://www.plrd.org/api/ai/search/?q=SYNTHETIC')).json()).total, 0) + for (const key of Object.keys(aiContentSources)) { + const original = aiContentSources[key][0] + const injected = { ...original, ...flag, slug, visibility, title: 'DO NOT EXPORT SYNTHETIC SENTINEL' } + const records = buildAiRecords({ ...aiContentSources, [key]: [...aiContentSources[key], injected] }) + assert.ok(!records.some(r => r.slug === slug), `${key}: ${JSON.stringify(flag)}`) + } + } + for (const slug of ['preview-synthetic', 'impact-preview-synthetic', 'open-lab', 'field-velocity', 'admin', 'account', 'edit', 'write']) { + const url = `https://www.plrd.org/blog/${slug}/` + const fixture = { ...seed, slug, id: `blog/${slug}`, sourceUrl: url, canonicalUrl: url } + const api = createAiAccess([fixture]) + assert.equal(api.detail('blog', slug).status, 404, slug) + assert.equal((await api.index().json()).coverage.total, 0) + } + for (const visibility of [undefined, {}, { version: 2, denied: false, notBefore: null }]) { + assert.equal(createAiAccess([{ ...seed, visibility }]).detail(seed.kind, seed.slug).status, 404) + } +}) + +for (const [word, prose] of [ + ['API', 'The published API supports reproducible research.'], + ['preview', 'This is a preview of the published findings.'], + ['edit', 'Researchers can edit a graph to compare hypotheses.'], +]) { + test(`ordinary public prose containing ${word} remains discoverable and readable`, async () => { + const seed = aiRecords.find(r => r.kind === 'blog' && r.canonicalKind === 'native') + for (const field of ['title', 'summary', 'body']) { + const fixture = { ...seed, [field]: prose } + const api = createAiAccess([fixture]) + assert.equal(api.detail(seed.kind, seed.slug).status, 200, field) + assert.equal((await api.detail(seed.kind, seed.slug).json()).record[field], prose) + assert.equal((await api.index().json()).coverage.total, 1, field) + assert.equal((await api.search(new Request(`https://www.plrd.org/api/ai/search/?q=${word}`)).json()).total, 1, field) + const original = aiContentSources.blogPosts.find(r => r.slug === seed.slug) + const mapped = buildAiRecords({ ...aiContentSources, blogPosts: [{ ...original, [field === 'body' ? 'markdown' : field]: prose }] }) + assert.equal(mapped.find(r => r.id === seed.id)?.[field], prose, `${field}: shared source mapper`) + } + }) +} + +test('private route references stay excluded from public prose and source links', async () => { + const seed = aiRecords.find(r => r.kind === 'blog' && r.canonicalKind === 'native') + for (const route of [ + '/admin/', '/account/', '/edit/', '/write/', '/api/', '/api/field-velocity/neurotech/', + '/blog/preview-synthetic/', '/blog/example/?preview=true', '/blog/example/#noindex', + '/lab/', '/open-lab/', '/blog/unlisted-example/', + ]) { + const url = `https://www.plrd.org${route}` + const linked = { ...seed, sources: [...seed.sources, { label: 'Private source', url }] } + assert.equal(createAiAccess([linked]).detail(seed.kind, seed.slug).status, 404, url) + for (const field of ['title', 'summary', 'body']) { + const api = createAiAccess([{ ...seed, [field]: `See [source](${url}) and ${route}` }]) + assert.equal(api.detail(seed.kind, seed.slug).status, 404, `${field}: ${route}`) + assert.equal((await api.index().json()).coverage.total, 0, `${field}: ${route}`) + assert.equal((await api.search(new Request('https://www.plrd.org/api/ai/search/?q=source')).json()).total, 0, `${field}: ${route}`) + } + } +}) + +test('direct detail cannot disclose private source links or unknown raw fields', async () => { + const seed = aiRecords.find(r => r.kind === 'blog') + for (const url of [ + 'https://www.plrd.org/admin/', 'https://www.plrd.org/api/field-velocity/neurotech/', + 'https://www.plrd.org/blog/example/?preview=true', 'https://github.com/protocol/plrd.org/blob/main/AGENTS.md', + 'https://user:password@example.org/', 'javascript:alert(1)', + ]) { + const fixture = { ...seed, sources: [...seed.sources, { label: 'Private source', url }] } + assert.equal(createAiAccess([fixture]).detail(seed.kind, seed.slug).status, 404, url) + } + const fixture = { ...seed, rawCmsSecret: 'DO_NOT_EXPORT_UNKNOWN_FIELDS' } + assert.ok(!(await createAiAccess([fixture]).detail(seed.kind, seed.slug).text()).includes('DO_NOT_EXPORT_UNKNOWN_FIELDS')) +}) + +test('public bylines never leak broken author slugs as display names', () => { + for (const [slug, name] of [['jbp-allison-duettmann', 'Allison Duettmann'], ['jbp-konrad-kording', 'Konrad Kording'], ['jbp-tom-oxley', 'Tom Oxley']]) { + const talk = aiRecords.find(r => r.kind === 'talk' && r.slug === slug) + assert.ok(talk.authors.some(a => a.name === name && a.url === null), slug) + } + assert.deepEqual(aiRecords.flatMap(r => r.authors).filter(a => a.url === null && /^[a-z]+(?:-[a-z]+)+$/.test(a.name)), []) +}) + +test('author resolution is exact, preserves display names, and never guesses an external identity', () => { + const { authors } = aiContentSources + assert.equal(resolveAiAuthor('david-markowitz').name, 'David A. Markowitz') + assert.ok(resolveAiAuthor('David A. Markowitz').url.endsWith('/david-markowitz/')) + assert.deepEqual(resolveAiAuthor('David Markowitz'), { name: 'David Markowitz', url: null }) + assert.deepEqual(resolveAiAuthor('Unknown External, Jr.'), { name: 'Unknown External, Jr.', url: null }) + assert.deepEqual(resolveAiAuthor('DAVID A. MARKOWITZ'), { name: 'DAVID A. MARKOWITZ', url: null }) + const person = authors.find(a => a.slug === 'david-markowitz') + assert.equal(resolveAiAuthor(person.slug, [{ ...person, visibility: contentVisibility({ draft: true }) }]), null) + assert.equal(resolveAiAuthor(person.name, [person, { ...person, slug: 'another' }]).url, null) +}) diff --git a/scripts/build-content.mjs b/scripts/build-content.mjs index 39a5530d..8d622984 100644 --- a/scripts/build-content.mjs +++ b/scripts/build-content.mjs @@ -3,6 +3,8 @@ import path from 'path' import matter from 'gray-matter' import { remark } from 'remark' import remarkHtml from 'remark-html' +import { readableMarkdown } from './readable-markdown.mjs' +import { contentVisibility } from './content-visibility.mjs' const ROOT = process.cwd() const CONTENT_DIR = path.join(ROOT, 'content') @@ -16,6 +18,12 @@ function renderMd(md) { return processor.processSync(md).toString() } +function publicBody(item, collection) { + const visibility = contentVisibility(item) + if (visibility.denied || (visibility.notBefore && Date.parse(visibility.notBefore) > Date.now()) || item.external_url) return '' + return readableMarkdown(renderMd(item.content), `https://www.plrd.org/${collection}/${item.slug}/`) +} + function readDir(dir) { const fullPath = path.join(CONTENT_DIR, dir) if (!fs.existsSync(fullPath)) return [] @@ -32,13 +40,13 @@ function readDir(dir) { if (filePath) { const raw = fs.readFileSync(filePath, 'utf-8') const { data, content } = matter(raw) - items.push({ slug: entry.name, ...data, content: content.trim() }) + items.push({ ...data, slug: entry.name, content: content.trim() }) } } else if (entry.name.endsWith('.md') && !entry.name.startsWith('_index')) { const raw = fs.readFileSync(path.join(fullPath, entry.name), 'utf-8') const { data, content } = matter(raw) const slug = entry.name.replace(/\.md$/, '') - items.push({ slug, ...data, content: content.trim() }) + items.push({ ...data, slug, content: content.trim() }) } } return items @@ -58,6 +66,7 @@ function buildPublications() { .filter((p) => !p.unaffiliated) .map((p) => ({ slug: p.slug, + visibility: contentVisibility(p), title: p.title || '', date: p.date || '', authors: p.authors || [], @@ -86,6 +95,7 @@ function buildAuthors() { } return { slug: a.slug, + visibility: contentVisibility(a), name: a.name || '', role: a.role || '', groups: a.groups || [], @@ -105,6 +115,7 @@ function buildAuthors() { function buildTalks() { const items = readDir('talks').map((t) => ({ slug: t.slug, + visibility: contentVisibility(t), title: t.title || '', date: t.date || '', venue: t.venue || '', @@ -122,9 +133,11 @@ function buildTalks() { function buildTutorials() { const items = readDir('tutorials').map((t) => ({ slug: t.slug, + visibility: contentVisibility(t), title: t.title || '', date: t.date || '', summary: t.summary || '', + markdown: publicBody(t, 'tutorials'), html: renderMd(t.content), })) return items @@ -247,6 +260,7 @@ async function buildBlog() { out.push({ slug: b.slug, + visibility: contentVisibility(b), title: b.title || '', date: b.date || '', summary: b.summary || '', @@ -256,6 +270,7 @@ async function buildBlog() { external_url: b.external_url || '', coverImage, html: renderMd(b.content), + markdown: publicBody(b, 'blog'), unlisted: b.unlisted === true, }) } @@ -314,6 +329,7 @@ function buildDependencyGraph() { function buildAreas() { const items = readDir('areas').map((a) => ({ slug: a.slug, + visibility: contentVisibility(a), title: a.title || '', date: a.date || '', summary: a.summary || '', diff --git a/scripts/content-visibility.mjs b/scripts/content-visibility.mjs new file mode 100644 index 00000000..bbcd185e --- /dev/null +++ b/scripts/content-visibility.mjs @@ -0,0 +1,16 @@ +/** Visibility metadata retained by every shared content mapper. */ +export function contentVisibility(data) { + const flags = ['unlisted', 'draft', 'preview', 'noindex', 'unaffiliated', 'private', 'hidden'] + const deniedFlag = flags.some(key => data[key] != null && data[key] !== false) + const robots = Array.isArray(data.robots) ? data.robots.join(', ') : data.robots + const noindex = typeof robots === 'string' ? /\b(noindex|none)\b/i.test(robots) + : robots && (robots.index === false || robots.googleBot?.index === false) + const statusDenied = ['status', 'visibility'].some(key => data[key] != null && !['public', 'published', 'listed'].includes(data[key])) + const dates = [data.date, data.publishDate, data.publish_date, data.publishedAt, data.publishAt].filter(value => value != null && value !== '') + const parsed = dates.map(value => new Date(value).getTime()) + return { + version: 1, + denied: Boolean(deniedFlag || noindex || statusDenied || data.published === false || data.listed === false || data.public === false || parsed.some(value => !Number.isFinite(value))), + notBefore: parsed.length && parsed.every(Number.isFinite) ? new Date(Math.max(...parsed)).toISOString() : null, + } +} diff --git a/scripts/readable-markdown.mjs b/scripts/readable-markdown.mjs new file mode 100644 index 00000000..fbc29e98 --- /dev/null +++ b/scripts/readable-markdown.mjs @@ -0,0 +1,42 @@ +import { JSDOM } from 'jsdom' + +// The same rendered body used by the native HTML page, made readable without +// scripts, CSS or interactive widgets. No remote fetches or transcript synthesis. +export function readableMarkdown(html, baseUrl) { + const doc = new JSDOM(html).window.document + function render(node) { + if (node.nodeType === 3) return node.textContent.replace(/\s+/g, ' ') + if (node.nodeType !== 1) return '' + const tag = node.tagName.toLowerCase() + if (['script', 'style', 'iframe', 'noscript', 'template'].includes(tag) || node.hasAttribute('hidden') || node.getAttribute('aria-hidden') === 'true') return '' + if (tag === 'svg') return node.getAttribute('aria-label') ? `\n\nDiagram: ${node.getAttribute('aria-label')}\n\n` : '' + const children = () => [...node.childNodes].map(render).join('') + const href = (value) => { + try { + const url = new URL(value, baseUrl) + if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) return '' + if (url.origin === new URL(baseUrl).origin && !url.pathname.endsWith('/') && !/\.[\w]+$/.test(url.pathname)) url.pathname += '/' + return url.href + } catch { return '' } + } + if (tag === 'a') { + const url = href(node.getAttribute('href') || '') + return url ? `[${children().trim()}](${url})` : children() + } + if (tag === 'img') return node.getAttribute('alt') ? `\n\nImage: ${node.getAttribute('alt')}\n\n` : '' + if (tag === 'pre') return `\n\n\`\`\`\n${node.textContent.trim()}\n\`\`\`\n\n` + if (tag === 'code') return `\`${node.textContent}\`` + if (tag === 'br') return '\n' + if (tag === 'hr') return '\n\n---\n\n' + if (/^h[1-6]$/.test(tag)) return `\n\n${'#'.repeat(Number(tag[1]))} ${children().trim()}\n\n` + if (['strong', 'b'].includes(tag)) return `**${children()}**` + if (['em', 'i'].includes(tag)) return `*${children()}*` + if (tag === 'li') return `\n${node.parentElement.tagName === 'OL' ? '1.' : '-'} ${children().trim()}\n` + if (tag === 'td' || tag === 'th') return `${children().trim()} | ` + if (tag === 'tr') return `\n| ${children()}\n` + if (tag === 'blockquote') return `\n\n> ${children().trim().replace(/\n/g, '\n> ')}\n\n` + if (['p', 'div', 'section', 'figure', 'ul', 'ol', 'table'].includes(tag)) return `\n\n${children().trim()}\n\n` + return children() + } + return [...doc.body.childNodes].map(render).join('').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim() +} diff --git a/src/app/ai/index.json/route.ts b/src/app/ai/index.json/route.ts new file mode 100644 index 00000000..8ee26dcd --- /dev/null +++ b/src/app/ai/index.json/route.ts @@ -0,0 +1,4 @@ +import { createAiAccess } from '@/lib/ai-access' + +export const dynamic = 'force-static' +export function GET() { return createAiAccess().index() } diff --git a/src/app/ai/markdown/[kind]/[slug]/route.ts b/src/app/ai/markdown/[kind]/[slug]/route.ts new file mode 100644 index 00000000..75f365a5 --- /dev/null +++ b/src/app/ai/markdown/[kind]/[slug]/route.ts @@ -0,0 +1,9 @@ +import { aiResponse, aiText, createAiAccess } from '@/lib/ai-access' +import { recordMarkdown } from '@/lib/ai-markdown' + +export const dynamic = 'force-dynamic' +export async function GET(_request: Request, { params }: { params: Promise<{ kind: string; slug: string }> }) { + const { kind, slug } = await params + const record = createAiAccess().get(kind, slug) + return record ? aiText(recordMarkdown(record)) : aiResponse({ error: 'Not found' }, 404) +} diff --git a/src/app/ai/page.tsx b/src/app/ai/page.tsx new file mode 100644 index 00000000..a658e8fd --- /dev/null +++ b/src/app/ai/page.tsx @@ -0,0 +1,69 @@ +import type { Metadata } from 'next' +import Link from 'next/link' +import Breadcrumb from '@/components/Breadcrumb' +import AiStarterPrompt from '@/components/AiStarterPrompt' +import { AI_STARTER_PROMPT, AI_SCOPE, AI_RIGHTS } from '@/lib/ai-content' +import { createAiAccess, coverage } from '@/lib/ai-access' + +export const metadata: Metadata = { + title: 'Use PL R&D with AI', + description: 'Source-linked research context, focused Markdown and read-only search for AI-assisted work.', + alternates: { canonical: '/ai/' }, +} + +export default function AiGuide() { + const records = createAiAccess().records() + const info = coverage(records) + return
+ +
+

Research, with sources attached

+

Use PL R&D with AI

+

Ask your assistant to explore our research, compare approaches, or find a paper. Start with a small index, fetch only what you need, and keep the original sources in the answer. No account, chatbot or special integration required.

+ +
+

Choose your context

+
+ {[ + ['/llms.txt', 'Start small · llms.txt', 'A compact map of the available context and its limits. Point an agent here first.'], + ['/llms-full.txt', 'Download covered context', 'One readable text file containing all covered records. Upload it to an assistant when URL fetching is unavailable.'], + ['/ai/index.json', 'JSON index', 'All record metadata, canonical and source URLs, coverage counts, source dates, and links to individual Markdown resources.'], + ].map(([url, title, description]) =>
+ {title} +

{description}

+
)} +
+
+
+

Follow a focus area

+

Each resource has a shared descriptor and links to the articles, papers and talks tagged to that area—not a duplicate of its live overview page.

+
    + {records.filter(r => r.kind === 'area').map(r =>
  • + {r.title} · Markdown +

    {r.summary}

    + Current area page → +
  • )} +
+
+
+

Search without a model

+

A read-only JSON endpoint searches titles, summaries, covered bodies and author display names. Results are deterministic: case-insensitive AND substring matching, ordered by stable record ID—not relevance or scientific quality.

+ /api/ai/search/?q=connectome&kind=publication&limit=5 +
+
q
Up to 200 characters. Omit for browsing.
+
kind
area, author, blog, publication, talk, tutorial
+
area
One of the focus-area slugs in the JSON index. Untagged records will not match an area filter.
+
limit / offset
Default 10 / 0. Limit 1–50; offset 0–10000. Follow nextOffset until null.
+
+

Invalid, repeated or unknown parameters return 400. Unknown resources return 404. JSON results contain stable jsonUrl and markdownUrl links for detail. Every resource works with a plain HTTP GET; no JavaScript is needed to fetch the data.

+
+
+

Know what the snapshot covers

+

{AI_SCOPE}

+

This snapshot contains {info.total} records: {Object.entries(info.counts).map(([kind, count]) => `${count} ${kind}`).join(', ')}. Source publication dates range from {info.dates.earliest?.slice(0, 10)} to {info.dates.latest?.slice(0, 10)}. These are data dates, not a claim that every page was updated then. Undated records have a null date.

+

Use the current homepage and About page for live overview copy. External articles retain their original canonical URLs; local summaries are not full-text republications. A listed author profile is not a guarantee of current employment.

+

{AI_RIGHTS}

+
+
+
+} diff --git a/src/app/ai/records/[kind]/[slug]/route.ts b/src/app/ai/records/[kind]/[slug]/route.ts new file mode 100644 index 00000000..0af747c5 --- /dev/null +++ b/src/app/ai/records/[kind]/[slug]/route.ts @@ -0,0 +1,7 @@ +import { createAiAccess } from '@/lib/ai-access' + +export const dynamic = 'force-dynamic' +export async function GET(_request: Request, { params }: { params: Promise<{ kind: string; slug: string }> }) { + const { kind, slug } = await params + return createAiAccess().detail(kind, slug) +} diff --git a/src/app/ai/topics/[slug]/route.ts b/src/app/ai/topics/[slug]/route.ts new file mode 100644 index 00000000..f08601e9 --- /dev/null +++ b/src/app/ai/topics/[slug]/route.ts @@ -0,0 +1,9 @@ +import { aiResponse, aiText } from '@/lib/ai-access' +import { topicMarkdown } from '@/lib/ai-markdown' + +export const dynamic = 'force-dynamic' +export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params + const markdown = topicMarkdown(slug) + return markdown ? aiText(markdown) : aiResponse({ error: 'Not found' }, 404) +} diff --git a/src/app/api/ai/search/route.ts b/src/app/api/ai/search/route.ts new file mode 100644 index 00000000..8bc720d8 --- /dev/null +++ b/src/app/api/ai/search/route.ts @@ -0,0 +1,4 @@ +import { createAiAccess } from '@/lib/ai-access' + +export const dynamic = 'force-dynamic' +export function GET(request: Request) { return createAiAccess().search(request) } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 510c3183..62168a2c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -96,6 +96,7 @@ export default function RootLayout({ return ( + {/* Apply theme before paint to avoid a flash of the wrong theme */}