Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion crates/quarto-yaml/claude-notes/implementation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Overview

This crate implements `YamlWithSourceInfo`, a data structure that wraps `yaml-rust2::Yaml` with source location tracking.
This crate implements `YamlWithSourceInfo`, a data structure that wraps `yaml-rust2::Yaml` with source location tracking. This uses the **owned data approach** as decided in the design discussion (see `/Users/cscheid/repos/github/cscheid/kyoto/claude-notes/session-logs/2025-10-13-yaml-lifetime-vs-owned-discussion.md`).

## Architecture Decision: Owned Data

Expand Down Expand Up @@ -159,3 +159,9 @@ impl MarkedEventReceiver for YamlBuilder {
3. **Unified SourceInfo** - Replace with project-wide SourceInfo type
4. **YAML tags** - Support for !expr and custom tags
5. **Multi-document** - Support YAML streams

## References

- Design document: `/Users/cscheid/repos/github/cscheid/kyoto/claude-notes/yaml-with-source-info-design.md`
- Session log: `/Users/cscheid/repos/github/cscheid/kyoto/claude-notes/session-logs/2025-10-13-yaml-lifetime-vs-owned-discussion.md`
- rust-analyzer patterns: `/Users/cscheid/repos/github/cscheid/kyoto/claude-notes/rust-analyzer-owned-data-patterns.md`
22 changes: 22 additions & 0 deletions ts-packages/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# TypeScript Packages

This directory contains standalone TypeScript packages associated with the Kyoto Rust workspace.

Following the convention used in Rust monorepos (similar to how `target/` contains build artifacts),
this `ts-packages/` directory contains TypeScript packages that complement the Rust crates.

## Packages

- **annotated-qmd** (`@quarto/annotated-qmd`): Converts quarto-markdown-pandoc JSON output
to AnnotatedParse structures compatible with quarto-cli's YAML validation infrastructure.

## Development

Each package is independent with its own `package.json` and can be developed/tested separately:

```bash
cd ts-packages/annotated-qmd
npm install
npm test
npm run build
```
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# @quarto/rust-qmd-json
# @quarto/annotated-qmd

Convert quarto-markdown-pandoc JSON output to AnnotatedParse structures with full source mapping.

Expand All @@ -11,14 +11,14 @@ infrastructure. It preserves complete source location information through the co
## Installation

```bash
npm install @quarto/rust-qmd-json
npm install @quarto/annotated-qmd
```

## Quick Start

```typescript
import { parseRustQmdMetadata } from '@quarto/rust-qmd-json';
import type { RustQmdJson } from '@quarto/rust-qmd-json';
import { parseRustQmdMetadata } from '@quarto/annotated-qmd';
import type { RustQmdJson } from '@quarto/annotated-qmd';

// JSON from quarto-markdown-pandoc
const json: RustQmdJson = {
Expand Down Expand Up @@ -58,7 +58,7 @@ Main entry point for converting quarto-markdown-pandoc JSON to AnnotatedParse.
**Example with error handling:**

```typescript
import { parseRustQmdMetadata } from '@quarto/rust-qmd-json';
import { parseRustQmdMetadata } from '@quarto/annotated-qmd';

const errorHandler = (msg: string, id?: number) => {
console.error(`SourceInfo error: ${msg}`, id);
Expand All @@ -81,15 +81,15 @@ import type {
SerializableSourceInfo,
SourceContext,
SourceInfoErrorHandler
} from '@quarto/rust-qmd-json';
} from '@quarto/annotated-qmd';
```

### Advanced Usage

For more control, you can use the underlying classes directly:

```typescript
import { SourceInfoReconstructor, MetadataConverter } from '@quarto/rust-qmd-json';
import { SourceInfoReconstructor, MetadataConverter } from '@quarto/annotated-qmd';

const reconstructor = new SourceInfoReconstructor(
json.source_pool,
Expand All @@ -115,24 +115,3 @@ npm test
# Clean
npm run clean
```

## Architecture

The conversion happens in two phases:

1. **SourceInfo Reconstruction**: Convert the pooled SourceInfo format from JSON into
MappedString objects that track source locations through transformation chains.

2. **Metadata Conversion**: Recursively convert MetaValue variants into AnnotatedParse
structures with proper source tracking. MetaInlines/MetaBlocks are treated as leaf
nodes with the JSON array structure preserved in the result.

## Design Decisions

- **Direct JSON Value Mapping**: MetaInlines and MetaBlocks are preserved as JSON arrays
in the `result` field, avoiding any text reconstruction
- **Source Tracking**: Every value can be traced back to original file location via SourceInfo
- **Compatible Types**: Produces AnnotatedParse structures compatible with existing validation code

See repository's `claude-notes/plans/2025-10-23-json-to-annotated-parse-conversion.md` for
detailed implementation plan.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@quarto/rust-qmd-json",
"version": "0.1.0",
"name": "@quarto/annotated-qmd",
"version": "0.1.1",
"description": "Convert quarto-markdown-pandoc JSON output to AnnotatedParse structures",
"license": "MIT",
"author": {
Expand All @@ -12,8 +12,7 @@
},
"repository": {
"type": "git",
"url": "git+https://github.com/quarto-dev/quarto.git",
"directory": "ts-packages/rust-qmd-json"
"url": "git+https://github.com/quarto-dev/quarto-markdown.git"
},
"type": "module",
"main": "dist/index.js",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* @quarto/rust-qmd-json
* @quarto/annotated-qmd
*
* Converts quarto-markdown-pandoc JSON output to AnnotatedParse structures
* compatible with quarto-cli's YAML validation infrastructure.
Expand Down Expand Up @@ -43,21 +43,22 @@ import type { SourceInfoErrorHandler } from './source-map.js';
*
* @example
* ```typescript
* import { parseRustQmdMetadata } from '@quarto/rust-qmd-json';
* import { parseRustQmdMetadata } from '@quarto/annotated-qmd';
*
* const json = {
* meta: {
* title: { t: 'MetaString', c: 'Hello', s: 0 }
* },
* blocks: [],
* source_pool: [
* { r: [11, 16], t: 0, d: 0 }
* ],
* source_context: {
* astContext: {
* sourceInfoPool: [
* { r: [11, 16], t: 0, d: 0 }
* ],
* files: [
* { id: 0, path: 'test.qmd', content: '---\ntitle: Hello\n---' }
* { name: 'test.qmd', content: '---\ntitle: Hello\n---' }
* ]
* }
* },
* 'pandoc-api-version': [1, 23, 1]
* };
*
* const metadata = parseRustQmdMetadata(json);
Expand All @@ -68,15 +69,27 @@ export function parseRustQmdMetadata(
json: RustQmdJson,
errorHandler?: SourceInfoErrorHandler
): AnnotatedParse {
// Normalize the JSON structure to internal format
const sourceContext = {
files: json.astContext.files.map((f, idx) => ({
id: idx,
path: f.name,
content: f.content || ''
}))
};

// 1. Create SourceInfoReconstructor with pool and context
const sourceReconstructor = new SourceInfoReconstructor(
json.source_pool,
json.source_context,
json.astContext.sourceInfoPool,
sourceContext,
errorHandler
);

// 2. Create MetadataConverter
const converter = new MetadataConverter(sourceReconstructor);
// 2. Create MetadataConverter with metaTopLevelKeySources
const converter = new MetadataConverter(
sourceReconstructor,
json.astContext.metaTopLevelKeySources
);

// 3. Convert metadata to AnnotatedParse
return converter.convertMeta(json.meta);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ function isTaggedSpan(obj: unknown): obj is {
* Converts metadata from quarto-markdown-pandoc JSON to AnnotatedParse
*/
export class MetadataConverter {
constructor(private sourceReconstructor: SourceInfoReconstructor) {}
constructor(
private sourceReconstructor: SourceInfoReconstructor,
private metaTopLevelKeySources?: Record<string, number>
) {}

/**
* Convert top-level metadata object to AnnotatedParse
Expand All @@ -58,7 +61,8 @@ export class MetadataConverter {
// Create a synthetic MetaMap for the top-level metadata
const entries: MetaMapEntry[] = Object.entries(jsonMeta).map(([key, value]) => ({
key,
key_source: value.s, // Use value's source for key (not ideal, but metadata doesn't include key sources)
// Use metaTopLevelKeySources if available, otherwise fall back to value's source
key_source: this.metaTopLevelKeySources?.[key] ?? value.s,
value
}));

Expand All @@ -83,8 +87,10 @@ export class MetadataConverter {

for (const [key, value] of Object.entries(jsonMeta)) {
// Create AnnotatedParse for key
const [keyStart, keyEnd] = this.sourceReconstructor.getOffsets(value.s);
const keySource = this.sourceReconstructor.toMappedString(value.s);
// Use metaTopLevelKeySources if available, otherwise fall back to value's source
const keySourceId = this.metaTopLevelKeySources?.[key] ?? value.s;
const [keyStart, keyEnd] = this.sourceReconstructor.getOffsets(keySourceId);
const keySource = this.sourceReconstructor.toMappedString(keySourceId);

const keyAP: AnnotatedParse = {
result: key,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,11 @@ export interface SerializableSourceInfo {

/**
* Type guard for Concat data structure
* Rust serializes Concat data as a plain array: [[source_info_id, offset, length], ...]
*/
function isConcatData(data: unknown): data is { pieces: [number, number, number][] } {
return (
typeof data === 'object' &&
data !== null &&
'pieces' in data &&
Array.isArray((data as { pieces: unknown }).pieces)
function isConcatData(data: unknown): data is [number, number, number][] {
return Array.isArray(data) && data.every(
item => Array.isArray(item) && item.length === 3
);
}

Expand Down Expand Up @@ -181,16 +179,17 @@ export class SourceInfoReconstructor {

/**
* Handle Concat SourceInfo type (t=2)
* Data format: {pieces: [[source_info_id, offset, length], ...]}
* Data format: [[source_info_id, offset, length], ...]
* (Rust serializes as plain array, not object with pieces field)
*/
private handleConcat(id: number, info: SerializableSourceInfo): MappedString {
// Runtime type check
if (!isConcatData(info.d)) {
this.errorHandler(`Invalid Concat data format (expected {pieces: [...]}), got ${typeof info.d}`, id);
this.errorHandler(`Invalid Concat data format (expected array of [id, offset, length]), got ${typeof info.d}`, id);
return asMappedString('');
}

const pieces = info.d.pieces;
const pieces = info.d; // Direct array access

// Build MappedString array from pieces
const mappedPieces: MappedString[] = [];
Expand Down Expand Up @@ -272,7 +271,7 @@ export class SourceInfoReconstructor {
this.errorHandler(`Invalid Concat data format`, id);
resolved = { file_id: -1, range: info.r };
} else {
const pieces = info.d.pieces;
const pieces = info.d; // Direct array access
if (pieces.length === 0) {
this.errorHandler(`Empty Concat pieces`, id);
resolved = { file_id: -1, range: info.r };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,18 +47,26 @@ export interface MetaMapEntry {
value: JsonMetaValue;
}

/**
* File information from Rust JSON output
*/
export interface RustFileInfo {
name: string; // File path/name
line_breaks?: number[]; // Byte offsets of newlines
total_length?: number; // Total file length in bytes
content?: string; // File content (populated by consumer)
}

/**
* Complete JSON output from quarto-markdown-pandoc
*/
export interface RustQmdJson {
meta: Record<string, JsonMetaValue>;
blocks: unknown[]; // Not used in metadata conversion
source_pool: SerializableSourceInfo[];
source_context: {
files: Array<{
id: number;
path: string;
content: string;
}>;
astContext: {
sourceInfoPool: SerializableSourceInfo[];
files: RustFileInfo[];
metaTopLevelKeySources?: Record<string, number>; // Maps metadata keys to SourceInfo IDs
};
'pandoc-api-version': [number, number, number];
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,16 @@ test('can convert complete JSON to AnnotatedParse', async () => {
author: { t: 'MetaString', c: 'Alice', s: 1 }
},
blocks: [],
source_pool: [
{ r: [11, 22], t: 0, d: 0 }, // "Hello World"
{ r: [31, 36], t: 0, d: 0 } // "Alice"
],
source_context: {
astContext: {
sourceInfoPool: [
{ r: [11, 22], t: 0, d: 0 }, // "Hello World"
{ r: [31, 36], t: 0, d: 0 } // "Alice"
],
files: [
{ id: 0, path: 'test.qmd', content: '---\ntitle: Hello World\nauthor: Alice\n---' }
{ name: 'test.qmd', content: '---\ntitle: Hello World\nauthor: Alice\n---' }
]
}
},
'pandoc-api-version': [1, 23, 1]
};

const result = parseRustQmdMetadata(json);
Expand All @@ -60,3 +61,34 @@ test('can convert complete JSON to AnnotatedParse', async () => {
assert.strictEqual((result.result as any).author, 'Alice');
assert.strictEqual(result.components.length, 4); // title key, title value, author key, author value
});

test('can parse math-with-attr.json', async () => {
const { parseRustQmdMetadata } = await import('../src/index.js');
const fs = await import('fs/promises');
const path = await import('path');
const { fileURLToPath } = await import('url');

// Get the directory of this test file
const __dirname = path.dirname(fileURLToPath(import.meta.url));

// Load JSON fixture from test/fixtures
const jsonPath = path.join(__dirname, 'fixtures', 'math-with-attr.json');
const jsonText = await fs.readFile(jsonPath, 'utf-8');
const json = JSON.parse(jsonText);

// Read the QMD file content from test/fixtures
const qmdPath = path.join(__dirname, 'fixtures', 'math-with-attr.qmd');
const qmdContent = await fs.readFile(qmdPath, 'utf-8');

// Populate file content (simulating what user would do)
for (const file of json.astContext.files) {
file.content = qmdContent;
}

const result = parseRustQmdMetadata(json);

// Basic validation that it didn't throw
assert.strictEqual(result.kind, 'mapping');
assert.ok(result.result);
assert.ok((result.result as any).title);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"astContext":{"files":[{"line_breaks":[3,35,39,40,94,95,124,125,128,177,195,196,256],"name":"math-with-attr.qmd","total_length":257}],"metaTopLevelKeySources":{"title":47},"sourceInfoPool":[{"d":0,"r":[0,4],"t":0},{"d":0,"r":[4,5],"t":0},{"d":0,"r":[5,9],"t":0},{"d":0,"r":[9,10],"t":0},{"d":0,"r":[12,22],"t":0},{"d":0,"r":[10,24],"t":0},{"d":0,"r":[0,257],"t":0},{"d":6,"r":[4,35],"t":1},{"d":7,"r":[7,31],"t":1},{"d":0,"r":[41,47],"t":0},{"d":0,"r":[47,48],"t":0},{"d":0,"r":[48,52],"t":0},{"d":0,"r":[52,53],"t":0},{"d":0,"r":[53,57],"t":0},{"d":0,"r":[57,58],"t":0},{"d":0,"r":[58,67],"t":0},{"d":0,"r":[67,68],"t":0},{"d":[[15,0,9],[16,9,1]],"r":[0,10],"t":2},{"d":0,"r":[68,69],"t":0},{"d":0,"r":[69,79],"t":0},{"d":0,"r":[0,0],"t":0},{"d":0,"r":[41,95],"t":0},{"d":0,"r":[96,103],"t":0},{"d":0,"r":[103,104],"t":0},{"d":0,"r":[104,108],"t":0},{"d":0,"r":[108,109],"t":0},{"d":0,"r":[109,113],"t":0},{"d":0,"r":[113,114],"t":0},{"d":0,"r":[114,123],"t":0},{"d":0,"r":[123,124],"t":0},{"d":[[28,0,9],[29,9,1]],"r":[0,10],"t":2},{"d":0,"r":[96,125],"t":0},{"d":0,"r":[126,180],"t":0},{"d":0,"r":[0,0],"t":0},{"d":0,"r":[126,196],"t":0},{"d":0,"r":[197,204],"t":0},{"d":0,"r":[204,205],"t":0},{"d":0,"r":[205,211],"t":0},{"d":0,"r":[211,212],"t":0},{"d":0,"r":[212,219],"t":0},{"d":0,"r":[219,220],"t":0},{"d":[[39,0,7],[40,7,1]],"r":[0,8],"t":2},{"d":0,"r":[220,221],"t":0},{"d":0,"r":[221,238],"t":0},{"d":0,"r":[0,0],"t":0},{"d":0,"r":[197,257],"t":0},{"d":6,"r":[4,35],"t":1},{"d":46,"r":[0,5],"t":1}]},"blocks":[{"c":[{"c":"Inline","s":9,"t":"Str"},{"s":10,"t":"Space"},{"c":"math","s":11,"t":"Str"},{"s":12,"t":"Space"},{"c":"with","s":13,"t":"Str"},{"s":14,"t":"Space"},{"c":"attribute:","s":17,"t":"Str"},{"s":18,"t":"Space"},{"c":[["eq-einstein",["quarto-math-with-attribute"],[]],[{"c":[{"t":"InlineMath"},"E = mc^2"],"s":19,"t":"Math"}]],"s":20,"t":"Span"}],"s":21,"t":"Para"},{"c":[{"c":"Display","s":22,"t":"Str"},{"s":23,"t":"Space"},{"c":"math","s":24,"t":"Str"},{"s":25,"t":"Space"},{"c":"with","s":26,"t":"Str"},{"s":27,"t":"Space"},{"c":"attribute:","s":30,"t":"Str"}],"s":31,"t":"Para"},{"c":[{"c":[["eq-gaussian",["quarto-math-with-attribute"],[]],[{"c":[{"t":"DisplayMath"},"\n\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}\n"],"s":32,"t":"Math"}]],"s":33,"t":"Span"}],"s":34,"t":"Para"},{"c":[{"c":"Another","s":35,"t":"Str"},{"s":36,"t":"Space"},{"c":"inline","s":37,"t":"Str"},{"s":38,"t":"Space"},{"c":"example:","s":41,"t":"Str"},{"s":42,"t":"Space"},{"c":[["eq-pythagorean",["quarto-math-with-attribute"],[]],[{"c":[{"t":"InlineMath"},"a^2 + b^2 = c^2"],"s":43,"t":"Math"}]],"s":44,"t":"Span"}],"s":45,"t":"Para"}],"meta":{"title":{"c":[{"c":"math","s":0,"t":"Str"},{"s":1,"t":"Space"},{"c":"with","s":2,"t":"Str"},{"s":3,"t":"Space"},{"c":[{"c":"attributes","s":4,"t":"Str"}],"s":5,"t":"Strong"}],"s":8,"t":"MetaInlines"}},"pandoc-api-version":[1,23,1]}
13 changes: 13 additions & 0 deletions ts-packages/annotated-qmd/test/fixtures/math-with-attr.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
title: math with **attributes**
---

Inline math with attribute: $E = mc^2$ {#eq-einstein}

Display math with attribute:

$$
\int_0^\infty e^{-x^2} dx = \frac{\sqrt{\pi}}{2}
$$ {#eq-gaussian}

Another inline example: $a^2 + b^2 = c^2$ {#eq-pythagorean}
Loading