Skip to content

feat(ai): entry transforms (sibling-artifact folding) + flat object-list tables#79

Merged
jpslav merged 4 commits into
mainfrom
feat/ai-entry-transforms-sibling-merge
Jun 19, 2026
Merged

feat(ai): entry transforms (sibling-artifact folding) + flat object-list tables#79
jpslav merged 4 commits into
mainfrom
feat/ai-entry-transforms-sibling-merge

Conversation

@jpslav

@jpslav jpslav commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Two improvements to the AI content exporter (packages/canopycms/src/ai/), plus a review-fix pass.

1. entryTransforms — fold in colocated sibling artifacts

A new per-entry-type hook on AIContentConfig:

entryTransforms?: Record<string, EntryTransformFn>
// (entry, { contentId, readSibling }) => Promise<string | undefined> | string | undefined
  • Runs once per entry (may be async); returns markdown appended after the entry's body/fields. Cached on AIEntry.appendedSections, so it flows into the per-entry file, the collection all.md, and bundles — no post-hoc string surgery, IO runs once.
  • Fires for every format including data-only JSON/YAML (unlike bodyTransforms, MD/MDX-only).
  • readSibling(name) is a traversal-guarded, directory-bound reader (rejects slashes/../absolute, null on missing, rejects symlinks via lstat). The entry's absolute path is never exposed, so it can't leak into published /ai/ output.
  • A throwing transform is logged and skipped — the entry still renders.

Brings the exporter to parity with the page-render path's colocated-artifact capability (meta.physicalPath), so adopters fold a machine-generated sibling (e.g. <contentId>.profile.json) into AI output natively. Per-entry isolation is intentional: a transform sees one entry + its colocated files, not other entries.

2. Flat object-list fields render as tables

renderListField now renders arrays of flat-scalar-object items as a compact GFM table instead of nested ### Label N headings (isFlatObjectList predicate + renderObjectListTable). Columns in schema order, empty cells for absent keys, pipes/backslashes escaped, newlines collapsed. Genuinely nested items (sub-lists/objects/long-form text, e.g. dataset.tables[].columns[]) keep the heading-per-item form. Deterministic (no content-length heuristic); fieldTransforms on the list field still override.

3. Review fixes

  • CodeQL js/incomplete-sanitization (High, Fix/package normalization #19): escapeTableCell now escapes backslashes before pipes, so a\|b can't leave an unescaped pipe that splits a GFM row.
  • Containment hardening: makeReadSibling uses fs.lstat instead of fs.stat, rejecting a symlink named as a bare filename inside the entry dir rather than following it out.

Files

  • ai/types.ts, ai/generate.ts (makeReadSibling, runEntryTransform), ai/json-to-markdown.ts (renderListField, isFlatObjectList, renderObjectListTable, escapeTableCell), ai/index.ts
  • Tests in ai/__tests__/generate.integration.test.ts + json-to-markdown.test.ts
  • Docs: README, ARCHITECTURE, CODEBASE_GUIDE, AGENTS, example1 config (commented)

Verification

  • src/ai suite: 142 passing (entry-transform flows, traversal + symlink guards, table rendering + escaping, once-per-entry caching). Typecheck clean; lint clean; CodeQL Fix/package normalization #19 addressed.

jpslav added 2 commits June 18, 2026 16:22
…rtifacts

AI export now exposes a per-entry-type `entryTransforms` hook on AIContentConfig.
It runs once per entry and appends returned markdown after the entry's body/fields,
flowing automatically into the per-entry file, collection all.md, and bundles. The
transform receives { contentId, readSibling }, where readSibling is a traversal-
guarded, directory-bound reader (the entry's absolute path is never exposed). Unlike
bodyTransforms, it fires for every format including data-only JSON/YAML entries.

This brings the exporter to parity with the page-render path's colocated-artifact
capability (meta.physicalPath), letting adopters fold a machine-generated sibling
(e.g. <contentId>.profile.json) into AI output natively instead of post-processing
the generated markdown.
…headings

The schema-driven serializer rendered an array-of-objects field as deeply nested,
one-datum-per-heading markdown (### Label N / #### subfield / value), which is verbose,
token-heavy, and hard to scan. Arrays whose object items have only single-line scalar
subfields now render as a compact GFM table (columns in schema order, empty cells for
absent keys, pipes/newlines escaped).

The heading-per-item form is retained for genuinely nested items (subfields that are
lists, objects, blocks, or long-form text — e.g. dataset.tables[].columns[]). This is a
deterministic, schema-driven decision (no content-length heuristic); fieldTransforms
still override the default for any field.
Comment thread packages/canopycms/src/ai/json-to-markdown.ts Fixed
Address branch review + CodeQL alert #19 (js/incomplete-sanitization):

- escapeTableCell now escapes backslashes before pipes, so a source value like
  'a\\|b' can't leave an unescaped pipe that splits a GFM table row.
- makeReadSibling uses fs.lstat instead of fs.stat, so a symlink named as a bare
  filename inside the entry dir is rejected (isFile() is false) rather than followed
  out of the directory into published /ai/ output.

Adds tests for both and a README note that table cells use default per-type rendering
(customize via a fieldTransforms entry on the list field itself).
@jpslav jpslav changed the title feat(ai): entryTransforms hook for folding in colocated sibling artifacts feat(ai): entry transforms (sibling-artifact folding) + flat object-list tables Jun 19, 2026
Round-2 review follow-ups (all Low/Nit):
- formatCellValue renders image subfields as \![](url) in table cells, matching the
  standalone image rendering instead of falling through to a bare URL string.
- Clarify isFlatObjectList JSDoc: it classifies by field type, not by inspecting values.

Future-tasks tidy: the sibling-merge and compact-object-list proposals shipped, so their
standalone files are removed (rationale now lives in code + ARCHITECTURE/README/CODEBASE_GUIDE).
The still-future ideas they raised (first-class sibling-artifact concept; list-form rendering
for description-heavy object lists) are migrated into ai-content-v2.md.
@jpslav jpslav requested a review from debshila June 19, 2026 00:25

@debshila debshila left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review

Solid, well-scoped, well-tested. No blocking issues — a few minor notes below, none of which need to gate merge. Verified against the source rather than the description.

Correctness — verified

  • "Computed once, flows to all three outputs" holds by object identity: processCollection mutates aiEntry.appendedSections before pushing to entries; the per-entry file, all.md (entries.map(e => entryToMarkdown(e, config))), and bundles (allEntries.filter(...).map(...)) all serialize the same object. entryToMarkdown reads the cached field and never re-runs the transform. calledFor).toEqual(['site']) correctly pins this.
  • Transform runs after the exclusion checks, so excluded entries trigger no IO. Good ordering.
  • appendedSections lives on AIEntry, not AIEntryMeta, so it can't leak into manifest.json.
  • The backslash-before-pipe escape is genuinely correct: a\|ba\\|ba\\\|b. The not.toContain('| a\\\\|b |') assertion is the right regression guard for CodeQL #19.

Security — makeReadSibling is robust

Layered correctly: rejects empty / / / \ / absolute / ..; defense-in-depth path.dirname(abs) !== resolvedDir (also catches .); lstat means a symlink's isFile() is false → rejected, so it can't redirect the read outside the dir. The absolute path is never handed to the transform, so it can't leak into /ai/ output. A null-byte name throws → caught in runEntryTransform → logged and skipped, entry still renders. Good.

Notes (minor, non-blocking)

  1. Subfield-level fieldTransforms are silently dropped in the table path. The old nested-heading branch rendered subfields via renderField(f, v, ...), which calls applyFieldTransform keyed by entryType + subfield name. The new renderObjectListTableformatCellValue never invokes applyFieldTransform. So an adopter who had fieldTransforms[entryType][<subfieldName>] affecting an object-list item now gets default rendering for flat lists. A transform on the list field itself still overrides (verified — checked in renderField before renderListField), but the subfield case isn't called out. Acceptable under "new code, no migrations," but worth a line in the PR description since it's a default-output change.

  2. Image-cell comment slightly overclaims parity. formatCellValue renders ![](${value}) (empty alt), while standalone renderField uses ![${label}](${value}). Empty alt is reasonable in a table (the column header carries the label) — just tighten the comment to say so rather than "Match the standalone image rendering."

  3. Entry transforms are awaited sequentially in the per-entry loop. With async readSibling IO this serializes across entries. Matches the existing serial await store.read(...) pattern, so not a regression — flagging only for large-content-set scaling.

  4. Multi-line string subfields collapse to one cell in tables (classification is by schema type, not value). Already acknowledged and deferred as a future task — good call avoiding a content-length heuristic.

Tests & docs

Test coverage is strong: traversal probes, symlink, throwing transform, once-per-entry caching, MD + JSON, content-ID resolution, missing-sibling baseline-equality, and table variants (empty cells, typed values, pipe/newline/backslash escaping, nested fallback). Docs are thorough and honest — the "appended content is published, don't append secrets/PII" and "static build reads repo checkout vs. runtime reads branch clone" warnings are exactly the gotchas an adopter would hit.

Recommendation: Approve. Optionally address notes 1 and 2; neither blocks merge.

@debshila debshila left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good.

@nathanstitt nathanstitt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good, left comments in in case they're useful

return value
.replace(/\\/g, '\\\\')
.replace(/\|/g, '\\|')
.replace(/\r?\n+/g, ' ')

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also would probably delete this, I doubt we care about old Mac line endings 😁

Small gap in the newline collapse: /\r?\n+/g matches CRLF and LF, but a lone \r (old-Mac line ending, or a stray \r mid-value) slips through unescaped. A raw CR inside a GFM cell can confuse some renderers. Could fold it into one pass, e.g. .replace(/[\r\n]+/g, ' '), which also collapses mixed \r/\n runs. Edge-casey given cells are schema scalars, but cheap to close.

? value.map((v) => resolveSelectLabel(field as SelectFieldConfig, v)).join(', ')
: resolveSelectLabel(field as SelectFieldConfig, value)
case 'image':
// Match the standalone image rendering (renderField) so a table cell stays an image reference.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tiny nit on the comment: the standalone path renders ![label](src) (alt = label) whereas the cell here is ![](src) with empty alt — so it isn't a 1:1 match. Defensible (the column header already carries the label, and alt text in a one-line cell adds little), but "Match the standalone image rendering" slightly overstates it. Maybe "…stays an image reference (alt omitted; the column header labels it)".

const transform = config?.entryTransforms?.[entry.entryType]
if (!transform) return
const ctx: EntryTransformContext = {
contentId: contentId ?? '',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is probably not an issue, I'd probably delete it on a normal review but leaving in case you wanna do something

When extractIdFromFilename returns null, contentId collapses to ''. The README sells the canonical usage as readSibling(`${contentId}.profile.json`) — with an empty id that silently becomes readSibling('.profile.json'), i.e. it probes a dotfile rather than surfacing "this entry has no stable id." In practice every entry filename should carry a Base58 id, so this may be unreachable — if so, an assert/continue reads clearer than a ?? '' that papers over the impossible case. If it is reachable, adopters get a confusing silent miss. Worth a quick think on which it is.

'select',
'reference',
'image',
])

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this one is all me: I'm probably reading this wrong, but since it's markdown, newlines are breaks, which means a string may not fit into a single line table cell cleanly?

@jpslav jpslav merged commit 6107405 into main Jun 19, 2026
5 checks passed
@jpslav jpslav deleted the feat/ai-entry-transforms-sibling-merge branch June 19, 2026 01:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants