Skip to content

Commit 7df054d

Browse files
committed
rename ts package, rehome at quarto-markdown
1 parent 109e601 commit 7df054d

17 files changed

Lines changed: 148 additions & 352 deletions

ts-packages/README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# TypeScript Packages
2+
3+
This directory contains standalone TypeScript packages associated with the Kyoto Rust workspace.
4+
5+
Following the convention used in Rust monorepos (similar to how `target/` contains build artifacts),
6+
this `ts-packages/` directory contains TypeScript packages that complement the Rust crates.
7+
8+
## Packages
9+
10+
- **annotated-qmd** (`@quarto/annotated-qmd`): Converts quarto-markdown-pandoc JSON output
11+
to AnnotatedParse structures compatible with quarto-cli's YAML validation infrastructure.
12+
13+
## Development
14+
15+
Each package is independent with its own `package.json` and can be developed/tested separately:
16+
17+
```bash
18+
cd ts-packages/annotated-qmd
19+
npm install
20+
npm test
21+
npm run build
22+
```
Lines changed: 7 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# @quarto/rust-qmd-json
1+
# @quarto/annotated-qmd
22

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

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

1313
```bash
14-
npm install @quarto/rust-qmd-json
14+
npm install @quarto/annotated-qmd
1515
```
1616

1717
## Quick Start
1818

1919
```typescript
20-
import { parseRustQmdMetadata } from '@quarto/rust-qmd-json';
21-
import type { RustQmdJson } from '@quarto/rust-qmd-json';
20+
import { parseRustQmdMetadata } from '@quarto/annotated-qmd';
21+
import type { RustQmdJson } from '@quarto/annotated-qmd';
2222

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

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

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

8787
### Advanced Usage
8888

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

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

9494
const reconstructor = new SourceInfoReconstructor(
9595
json.source_pool,
@@ -115,24 +115,3 @@ npm test
115115
# Clean
116116
npm run clean
117117
```
118-
119-
## Architecture
120-
121-
The conversion happens in two phases:
122-
123-
1. **SourceInfo Reconstruction**: Convert the pooled SourceInfo format from JSON into
124-
MappedString objects that track source locations through transformation chains.
125-
126-
2. **Metadata Conversion**: Recursively convert MetaValue variants into AnnotatedParse
127-
structures with proper source tracking. MetaInlines/MetaBlocks are treated as leaf
128-
nodes with the JSON array structure preserved in the result.
129-
130-
## Design Decisions
131-
132-
- **Direct JSON Value Mapping**: MetaInlines and MetaBlocks are preserved as JSON arrays
133-
in the `result` field, avoiding any text reconstruction
134-
- **Source Tracking**: Every value can be traced back to original file location via SourceInfo
135-
- **Compatible Types**: Produces AnnotatedParse structures compatible with existing validation code
136-
137-
See repository's `claude-notes/plans/2025-10-23-json-to-annotated-parse-conversion.md` for
138-
detailed implementation plan.
Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
2-
"name": "@quarto/rust-qmd-json",
3-
"version": "0.1.0",
2+
"name": "@quarto/annotated-qmd",
3+
"version": "0.1.1",
44
"description": "Convert quarto-markdown-pandoc JSON output to AnnotatedParse structures",
55
"license": "MIT",
66
"author": {
@@ -12,8 +12,7 @@
1212
},
1313
"repository": {
1414
"type": "git",
15-
"url": "git+https://github.com/quarto-dev/quarto.git",
16-
"directory": "ts-packages/rust-qmd-json"
15+
"url": "git+https://github.com/quarto-dev/quarto-markdown.git"
1716
},
1817
"type": "module",
1918
"main": "dist/index.js",
Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* @quarto/rust-qmd-json
2+
* @quarto/annotated-qmd
33
*
44
* Converts quarto-markdown-pandoc JSON output to AnnotatedParse structures
55
* compatible with quarto-cli's YAML validation infrastructure.
@@ -43,21 +43,22 @@ import type { SourceInfoErrorHandler } from './source-map.js';
4343
*
4444
* @example
4545
* ```typescript
46-
* import { parseRustQmdMetadata } from '@quarto/rust-qmd-json';
46+
* import { parseRustQmdMetadata } from '@quarto/annotated-qmd';
4747
*
4848
* const json = {
4949
* meta: {
5050
* title: { t: 'MetaString', c: 'Hello', s: 0 }
5151
* },
5252
* blocks: [],
53-
* source_pool: [
54-
* { r: [11, 16], t: 0, d: 0 }
55-
* ],
56-
* source_context: {
53+
* astContext: {
54+
* sourceInfoPool: [
55+
* { r: [11, 16], t: 0, d: 0 }
56+
* ],
5757
* files: [
58-
* { id: 0, path: 'test.qmd', content: '---\ntitle: Hello\n---' }
58+
* { name: 'test.qmd', content: '---\ntitle: Hello\n---' }
5959
* ]
60-
* }
60+
* },
61+
* 'pandoc-api-version': [1, 23, 1]
6162
* };
6263
*
6364
* const metadata = parseRustQmdMetadata(json);
@@ -68,15 +69,27 @@ export function parseRustQmdMetadata(
6869
json: RustQmdJson,
6970
errorHandler?: SourceInfoErrorHandler
7071
): AnnotatedParse {
72+
// Normalize the JSON structure to internal format
73+
const sourceContext = {
74+
files: json.astContext.files.map((f, idx) => ({
75+
id: idx,
76+
path: f.name,
77+
content: f.content || ''
78+
}))
79+
};
80+
7181
// 1. Create SourceInfoReconstructor with pool and context
7282
const sourceReconstructor = new SourceInfoReconstructor(
73-
json.source_pool,
74-
json.source_context,
83+
json.astContext.sourceInfoPool,
84+
sourceContext,
7585
errorHandler
7686
);
7787

78-
// 2. Create MetadataConverter
79-
const converter = new MetadataConverter(sourceReconstructor);
88+
// 2. Create MetadataConverter with metaTopLevelKeySources
89+
const converter = new MetadataConverter(
90+
sourceReconstructor,
91+
json.astContext.metaTopLevelKeySources
92+
);
8093

8194
// 3. Convert metadata to AnnotatedParse
8295
return converter.convertMeta(json.meta);

ts-packages/rust-qmd-json/src/meta-converter.ts renamed to ts-packages/annotated-qmd/src/meta-converter.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,10 @@ function isTaggedSpan(obj: unknown): obj is {
4949
* Converts metadata from quarto-markdown-pandoc JSON to AnnotatedParse
5050
*/
5151
export class MetadataConverter {
52-
constructor(private sourceReconstructor: SourceInfoReconstructor) {}
52+
constructor(
53+
private sourceReconstructor: SourceInfoReconstructor,
54+
private metaTopLevelKeySources?: Record<string, number>
55+
) {}
5356

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

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

8488
for (const [key, value] of Object.entries(jsonMeta)) {
8589
// Create AnnotatedParse for key
86-
const [keyStart, keyEnd] = this.sourceReconstructor.getOffsets(value.s);
87-
const keySource = this.sourceReconstructor.toMappedString(value.s);
90+
// Use metaTopLevelKeySources if available, otherwise fall back to value's source
91+
const keySourceId = this.metaTopLevelKeySources?.[key] ?? value.s;
92+
const [keyStart, keyEnd] = this.sourceReconstructor.getOffsets(keySourceId);
93+
const keySource = this.sourceReconstructor.toMappedString(keySourceId);
8894

8995
const keyAP: AnnotatedParse = {
9096
result: key,

ts-packages/rust-qmd-json/src/source-map.ts renamed to ts-packages/annotated-qmd/src/source-map.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,11 @@ export interface SerializableSourceInfo {
1818

1919
/**
2020
* Type guard for Concat data structure
21+
* Rust serializes Concat data as a plain array: [[source_info_id, offset, length], ...]
2122
*/
22-
function isConcatData(data: unknown): data is { pieces: [number, number, number][] } {
23-
return (
24-
typeof data === 'object' &&
25-
data !== null &&
26-
'pieces' in data &&
27-
Array.isArray((data as { pieces: unknown }).pieces)
23+
function isConcatData(data: unknown): data is [number, number, number][] {
24+
return Array.isArray(data) && data.every(
25+
item => Array.isArray(item) && item.length === 3
2826
);
2927
}
3028

@@ -181,16 +179,17 @@ export class SourceInfoReconstructor {
181179

182180
/**
183181
* Handle Concat SourceInfo type (t=2)
184-
* Data format: {pieces: [[source_info_id, offset, length], ...]}
182+
* Data format: [[source_info_id, offset, length], ...]
183+
* (Rust serializes as plain array, not object with pieces field)
185184
*/
186185
private handleConcat(id: number, info: SerializableSourceInfo): MappedString {
187186
// Runtime type check
188187
if (!isConcatData(info.d)) {
189-
this.errorHandler(`Invalid Concat data format (expected {pieces: [...]}), got ${typeof info.d}`, id);
188+
this.errorHandler(`Invalid Concat data format (expected array of [id, offset, length]), got ${typeof info.d}`, id);
190189
return asMappedString('');
191190
}
192191

193-
const pieces = info.d.pieces;
192+
const pieces = info.d; // Direct array access
194193

195194
// Build MappedString array from pieces
196195
const mappedPieces: MappedString[] = [];
@@ -272,7 +271,7 @@ export class SourceInfoReconstructor {
272271
this.errorHandler(`Invalid Concat data format`, id);
273272
resolved = { file_id: -1, range: info.r };
274273
} else {
275-
const pieces = info.d.pieces;
274+
const pieces = info.d; // Direct array access
276275
if (pieces.length === 0) {
277276
this.errorHandler(`Empty Concat pieces`, id);
278277
resolved = { file_id: -1, range: info.r };
Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,26 @@ export interface MetaMapEntry {
4747
value: JsonMetaValue;
4848
}
4949

50+
/**
51+
* File information from Rust JSON output
52+
*/
53+
export interface RustFileInfo {
54+
name: string; // File path/name
55+
line_breaks?: number[]; // Byte offsets of newlines
56+
total_length?: number; // Total file length in bytes
57+
content?: string; // File content (populated by consumer)
58+
}
59+
5060
/**
5161
* Complete JSON output from quarto-markdown-pandoc
5262
*/
5363
export interface RustQmdJson {
5464
meta: Record<string, JsonMetaValue>;
5565
blocks: unknown[]; // Not used in metadata conversion
56-
source_pool: SerializableSourceInfo[];
57-
source_context: {
58-
files: Array<{
59-
id: number;
60-
path: string;
61-
content: string;
62-
}>;
66+
astContext: {
67+
sourceInfoPool: SerializableSourceInfo[];
68+
files: RustFileInfo[];
69+
metaTopLevelKeySources?: Record<string, number>; // Maps metadata keys to SourceInfo IDs
6370
};
71+
'pandoc-api-version': [number, number, number];
6472
}

ts-packages/rust-qmd-json/test/basic.test.ts renamed to ts-packages/annotated-qmd/test/basic.test.ts

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,16 @@ test('can convert complete JSON to AnnotatedParse', async () => {
4141
author: { t: 'MetaString', c: 'Alice', s: 1 }
4242
},
4343
blocks: [],
44-
source_pool: [
45-
{ r: [11, 22], t: 0, d: 0 }, // "Hello World"
46-
{ r: [31, 36], t: 0, d: 0 } // "Alice"
47-
],
48-
source_context: {
44+
astContext: {
45+
sourceInfoPool: [
46+
{ r: [11, 22], t: 0, d: 0 }, // "Hello World"
47+
{ r: [31, 36], t: 0, d: 0 } // "Alice"
48+
],
4949
files: [
50-
{ id: 0, path: 'test.qmd', content: '---\ntitle: Hello World\nauthor: Alice\n---' }
50+
{ name: 'test.qmd', content: '---\ntitle: Hello World\nauthor: Alice\n---' }
5151
]
52-
}
52+
},
53+
'pandoc-api-version': [1, 23, 1]
5354
};
5455

5556
const result = parseRustQmdMetadata(json);
@@ -60,3 +61,34 @@ test('can convert complete JSON to AnnotatedParse', async () => {
6061
assert.strictEqual((result.result as any).author, 'Alice');
6162
assert.strictEqual(result.components.length, 4); // title key, title value, author key, author value
6263
});
64+
65+
test('can parse math-with-attr.json', async () => {
66+
const { parseRustQmdMetadata } = await import('../src/index.js');
67+
const fs = await import('fs/promises');
68+
const path = await import('path');
69+
const { fileURLToPath } = await import('url');
70+
71+
// Get the directory of this test file
72+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
73+
74+
// Load JSON fixture from test/fixtures
75+
const jsonPath = path.join(__dirname, 'fixtures', 'math-with-attr.json');
76+
const jsonText = await fs.readFile(jsonPath, 'utf-8');
77+
const json = JSON.parse(jsonText);
78+
79+
// Read the QMD file content from test/fixtures
80+
const qmdPath = path.join(__dirname, 'fixtures', 'math-with-attr.qmd');
81+
const qmdContent = await fs.readFile(qmdPath, 'utf-8');
82+
83+
// Populate file content (simulating what user would do)
84+
for (const file of json.astContext.files) {
85+
file.content = qmdContent;
86+
}
87+
88+
const result = parseRustQmdMetadata(json);
89+
90+
// Basic validation that it didn't throw
91+
assert.strictEqual(result.kind, 'mapping');
92+
assert.ok(result.result);
93+
assert.ok((result.result as any).title);
94+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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]}

0 commit comments

Comments
 (0)