patram 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/bin/patram.js +169 -0
- package/bin/patram.test.js +184 -0
- package/lib/build-graph.js +222 -0
- package/lib/build-graph.test.js +141 -0
- package/lib/build-graph.types.ts +23 -0
- package/lib/check-graph.js +125 -0
- package/lib/check-graph.test.js +103 -0
- package/lib/list-source-files.js +44 -0
- package/lib/list-source-files.test.js +101 -0
- package/lib/load-patram-config.js +244 -0
- package/lib/load-patram-config.test.js +211 -0
- package/lib/load-patram-config.types.ts +23 -0
- package/lib/parse-claims.js +178 -0
- package/lib/parse-claims.test.js +113 -0
- package/lib/parse-claims.types.ts +27 -0
- package/lib/patram-config.js +194 -0
- package/lib/patram-config.test.js +147 -0
- package/lib/patram-config.types.ts +33 -0
- package/package.json +64 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { LoadPatramConfigResult, PatramDiagnostic, PatramRepoConfig } from './load-patram-config.types.ts';
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
6
|
+
import { resolve } from 'node:path';
|
|
7
|
+
import process from 'node:process';
|
|
8
|
+
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
|
|
11
|
+
const CONFIG_FILE_NAME = '.patram.json';
|
|
12
|
+
|
|
13
|
+
const stored_query_schema = z
|
|
14
|
+
.object({
|
|
15
|
+
where: z.string().min(1, 'Stored query "where" must not be empty.'),
|
|
16
|
+
})
|
|
17
|
+
.strict();
|
|
18
|
+
|
|
19
|
+
const patram_repo_config_schema = z
|
|
20
|
+
.object({
|
|
21
|
+
include: z
|
|
22
|
+
.array(z.string().min(1, 'Include globs must not be empty.'))
|
|
23
|
+
.min(1, 'Include must contain at least one glob.'),
|
|
24
|
+
queries: z.record(z.string().min(1), stored_query_schema),
|
|
25
|
+
})
|
|
26
|
+
.strict();
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Load and validate the repo Patram config.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} [project_directory]
|
|
32
|
+
* @returns {Promise<LoadPatramConfigResult>}
|
|
33
|
+
*/
|
|
34
|
+
export async function loadPatramConfig(project_directory = process.cwd()) {
|
|
35
|
+
const config_file_path = resolve(project_directory, CONFIG_FILE_NAME);
|
|
36
|
+
const config_source = await readConfigSource(config_file_path);
|
|
37
|
+
|
|
38
|
+
if (config_source === null) {
|
|
39
|
+
return createLoadResult(null, [createMissingConfigDiagnostic()]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const parse_result = parseConfigJson(config_source);
|
|
43
|
+
|
|
44
|
+
if (!parse_result.success) {
|
|
45
|
+
return createLoadResult(null, [parse_result.diagnostic]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const config_result = patram_repo_config_schema.safeParse(parse_result.value);
|
|
49
|
+
|
|
50
|
+
if (!config_result.success) {
|
|
51
|
+
return createLoadResult(
|
|
52
|
+
null,
|
|
53
|
+
config_result.error.issues.map(createValidationDiagnostic),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return createLoadResult(config_result.data, []);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {string} config_file_path
|
|
62
|
+
* @returns {Promise<string | null>}
|
|
63
|
+
*/
|
|
64
|
+
async function readConfigSource(config_file_path) {
|
|
65
|
+
try {
|
|
66
|
+
return await readFile(config_file_path, 'utf8');
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (isMissingFileError(error)) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {string} config_source
|
|
78
|
+
* @returns {{ success: true, value: unknown } | { success: false, diagnostic: PatramDiagnostic }}
|
|
79
|
+
*/
|
|
80
|
+
function parseConfigJson(config_source) {
|
|
81
|
+
try {
|
|
82
|
+
return {
|
|
83
|
+
success: true,
|
|
84
|
+
value: JSON.parse(config_source),
|
|
85
|
+
};
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error instanceof SyntaxError) {
|
|
88
|
+
return {
|
|
89
|
+
diagnostic: createInvalidJsonDiagnostic(config_source, error),
|
|
90
|
+
success: false,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {PatramRepoConfig | null} config
|
|
100
|
+
* @param {PatramDiagnostic[]} diagnostics
|
|
101
|
+
* @returns {LoadPatramConfigResult}
|
|
102
|
+
*/
|
|
103
|
+
function createLoadResult(config, diagnostics) {
|
|
104
|
+
return {
|
|
105
|
+
config,
|
|
106
|
+
config_path: CONFIG_FILE_NAME,
|
|
107
|
+
diagnostics,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @returns {PatramDiagnostic}
|
|
113
|
+
*/
|
|
114
|
+
function createMissingConfigDiagnostic() {
|
|
115
|
+
return {
|
|
116
|
+
code: 'config.not_found',
|
|
117
|
+
column: 1,
|
|
118
|
+
level: 'error',
|
|
119
|
+
line: 1,
|
|
120
|
+
message: 'Config file ".patram.json" was not found.',
|
|
121
|
+
path: CONFIG_FILE_NAME,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @param {string} config_source
|
|
127
|
+
* @param {SyntaxError} error
|
|
128
|
+
* @returns {PatramDiagnostic}
|
|
129
|
+
*/
|
|
130
|
+
function createInvalidJsonDiagnostic(config_source, error) {
|
|
131
|
+
const origin = getJsonSyntaxOrigin(config_source, error.message);
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
code: 'config.invalid_json',
|
|
135
|
+
column: origin.column,
|
|
136
|
+
level: 'error',
|
|
137
|
+
line: origin.line,
|
|
138
|
+
message: 'Invalid JSON syntax.',
|
|
139
|
+
path: CONFIG_FILE_NAME,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @param {import('zod').core.$ZodIssue} issue
|
|
145
|
+
* @returns {PatramDiagnostic}
|
|
146
|
+
*/
|
|
147
|
+
function createValidationDiagnostic(issue) {
|
|
148
|
+
const issue_path = formatIssuePath(issue.path);
|
|
149
|
+
|
|
150
|
+
if (issue_path) {
|
|
151
|
+
return {
|
|
152
|
+
code: 'config.invalid',
|
|
153
|
+
column: 1,
|
|
154
|
+
level: 'error',
|
|
155
|
+
line: 1,
|
|
156
|
+
message: `Invalid config at "${issue_path}": ${issue.message}`,
|
|
157
|
+
path: CONFIG_FILE_NAME,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
code: 'config.invalid',
|
|
163
|
+
column: 1,
|
|
164
|
+
level: 'error',
|
|
165
|
+
line: 1,
|
|
166
|
+
message: `Invalid config: ${issue.message}`,
|
|
167
|
+
path: CONFIG_FILE_NAME,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* @param {unknown} error
|
|
173
|
+
* @returns {error is NodeJS.ErrnoException}
|
|
174
|
+
*/
|
|
175
|
+
function isMissingFileError(error) {
|
|
176
|
+
if (!(error instanceof Error)) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return 'code' in error && error.code === 'ENOENT';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* @param {string} config_source
|
|
185
|
+
* @param {string} error_message
|
|
186
|
+
* @returns {{ line: number, column: number }}
|
|
187
|
+
*/
|
|
188
|
+
function getJsonSyntaxOrigin(config_source, error_message) {
|
|
189
|
+
const position_match = error_message.match(/position (?<offset>\d+)/du);
|
|
190
|
+
|
|
191
|
+
if (position_match?.groups?.offset) {
|
|
192
|
+
const offset = Number.parseInt(position_match.groups.offset, 10);
|
|
193
|
+
|
|
194
|
+
return getLineAndColumnFromOffset(config_source, offset);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const token_match = error_message.match(/Unexpected token '(?<token>.)'/u);
|
|
198
|
+
|
|
199
|
+
if (token_match?.groups?.token) {
|
|
200
|
+
const offset = config_source.lastIndexOf(token_match.groups.token);
|
|
201
|
+
|
|
202
|
+
if (offset >= 0) {
|
|
203
|
+
return getLineAndColumnFromOffset(config_source, offset);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
column: 1,
|
|
209
|
+
line: 1,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* @param {(string | number | symbol | undefined)[]} issue_path
|
|
215
|
+
* @returns {string}
|
|
216
|
+
*/
|
|
217
|
+
function formatIssuePath(issue_path) {
|
|
218
|
+
return issue_path.map(String).join('.');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* @param {string} source_text
|
|
223
|
+
* @param {number} offset
|
|
224
|
+
* @returns {{ line: number, column: number }}
|
|
225
|
+
*/
|
|
226
|
+
function getLineAndColumnFromOffset(source_text, offset) {
|
|
227
|
+
let line_number = 1;
|
|
228
|
+
let column_number = 1;
|
|
229
|
+
|
|
230
|
+
for (const character of source_text.slice(0, offset)) {
|
|
231
|
+
if (character === '\n') {
|
|
232
|
+
line_number += 1;
|
|
233
|
+
column_number = 1;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
column_number += 1;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
column: column_number,
|
|
242
|
+
line: line_number,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { afterEach, expect, it } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { loadPatramConfig } from './load-patram-config.js';
|
|
8
|
+
|
|
9
|
+
const test_context = createTestContext();
|
|
10
|
+
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
await cleanupTestContext(test_context);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('loads and validates .patram.json from a project directory', async () => {
|
|
16
|
+
test_context.project_directory = await createTempProjectDirectory();
|
|
17
|
+
await writeProjectConfig(
|
|
18
|
+
test_context.project_directory,
|
|
19
|
+
createValidConfigSource(),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const load_result = await loadPatramConfig(test_context.project_directory);
|
|
23
|
+
|
|
24
|
+
expect(load_result).toEqual({
|
|
25
|
+
config: {
|
|
26
|
+
include: ['docs/**/*.md'],
|
|
27
|
+
queries: {
|
|
28
|
+
pending: {
|
|
29
|
+
where: 'kind=task and status=pending',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
config_path: '.patram.json',
|
|
34
|
+
diagnostics: [],
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('defaults to the current working directory', async () => {
|
|
39
|
+
test_context.project_directory = await createTempProjectDirectory();
|
|
40
|
+
await writeProjectConfig(
|
|
41
|
+
test_context.project_directory,
|
|
42
|
+
createValidConfigSource(),
|
|
43
|
+
);
|
|
44
|
+
process.chdir(test_context.project_directory);
|
|
45
|
+
|
|
46
|
+
const load_result = await loadPatramConfig();
|
|
47
|
+
|
|
48
|
+
expect(load_result.config?.queries.pending.where).toBe(
|
|
49
|
+
'kind=task and status=pending',
|
|
50
|
+
);
|
|
51
|
+
expect(load_result.diagnostics).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('reports a missing config file', async () => {
|
|
55
|
+
test_context.project_directory = await createTempProjectDirectory();
|
|
56
|
+
const load_result = await loadPatramConfig(test_context.project_directory);
|
|
57
|
+
|
|
58
|
+
expect(load_result.config).toBeNull();
|
|
59
|
+
expect(load_result.diagnostics).toEqual([
|
|
60
|
+
{
|
|
61
|
+
code: 'config.not_found',
|
|
62
|
+
column: 1,
|
|
63
|
+
level: 'error',
|
|
64
|
+
line: 1,
|
|
65
|
+
message: 'Config file ".patram.json" was not found.',
|
|
66
|
+
path: '.patram.json',
|
|
67
|
+
},
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('reports invalid JSON syntax', async () => {
|
|
72
|
+
test_context.project_directory = await createTempProjectDirectory();
|
|
73
|
+
await writeProjectConfig(test_context.project_directory, createBrokenJson());
|
|
74
|
+
|
|
75
|
+
const load_result = await loadPatramConfig(test_context.project_directory);
|
|
76
|
+
|
|
77
|
+
expect(load_result.config).toBeNull();
|
|
78
|
+
expect(load_result.diagnostics).toEqual([
|
|
79
|
+
{
|
|
80
|
+
code: 'config.invalid_json',
|
|
81
|
+
column: 1,
|
|
82
|
+
level: 'error',
|
|
83
|
+
line: 3,
|
|
84
|
+
message: 'Invalid JSON syntax.',
|
|
85
|
+
path: '.patram.json',
|
|
86
|
+
},
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('reports config validation diagnostics', async () => {
|
|
91
|
+
test_context.project_directory = await createTempProjectDirectory();
|
|
92
|
+
await writeProjectConfig(
|
|
93
|
+
test_context.project_directory,
|
|
94
|
+
createInvalidConfigSource(),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const load_result = await loadPatramConfig(test_context.project_directory);
|
|
98
|
+
|
|
99
|
+
expect(load_result.config).toBeNull();
|
|
100
|
+
expect(load_result.diagnostics).toEqual([
|
|
101
|
+
{
|
|
102
|
+
code: 'config.invalid',
|
|
103
|
+
column: 1,
|
|
104
|
+
level: 'error',
|
|
105
|
+
line: 1,
|
|
106
|
+
message:
|
|
107
|
+
'Invalid config at "include": Include must contain at least one glob.',
|
|
108
|
+
path: '.patram.json',
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
code: 'config.invalid',
|
|
112
|
+
column: 1,
|
|
113
|
+
level: 'error',
|
|
114
|
+
line: 1,
|
|
115
|
+
message:
|
|
116
|
+
'Invalid config at "queries.pending.where": Stored query "where" must not be empty.',
|
|
117
|
+
path: '.patram.json',
|
|
118
|
+
},
|
|
119
|
+
]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Create a temporary project directory.
|
|
124
|
+
*
|
|
125
|
+
* @returns {Promise<string>}
|
|
126
|
+
*/
|
|
127
|
+
async function createTempProjectDirectory() {
|
|
128
|
+
return mkdtemp(join(tmpdir(), 'patram-load-config-'));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Write a project config file.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} project_directory
|
|
135
|
+
* @param {string} config_source
|
|
136
|
+
*/
|
|
137
|
+
async function writeProjectConfig(project_directory, config_source) {
|
|
138
|
+
await writeFile(join(project_directory, '.patram.json'), config_source);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Remove a temporary directory tree.
|
|
143
|
+
*
|
|
144
|
+
* @param {string} project_directory
|
|
145
|
+
*/
|
|
146
|
+
async function removeDirectory(project_directory) {
|
|
147
|
+
await rm(project_directory, { force: true, recursive: true });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Create invalid JSON for parser tests.
|
|
152
|
+
*
|
|
153
|
+
* @returns {string}
|
|
154
|
+
*/
|
|
155
|
+
function createBrokenJson() {
|
|
156
|
+
return ['{', ' "include": [', '}'].join('\n');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Create invalid config JSON.
|
|
161
|
+
*
|
|
162
|
+
* @returns {string}
|
|
163
|
+
*/
|
|
164
|
+
function createInvalidConfigSource() {
|
|
165
|
+
return JSON.stringify({
|
|
166
|
+
include: [],
|
|
167
|
+
queries: {
|
|
168
|
+
pending: {
|
|
169
|
+
where: '',
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Create valid config JSON.
|
|
177
|
+
*
|
|
178
|
+
* @returns {string}
|
|
179
|
+
*/
|
|
180
|
+
function createValidConfigSource() {
|
|
181
|
+
return JSON.stringify({
|
|
182
|
+
include: ['docs/**/*.md'],
|
|
183
|
+
queries: {
|
|
184
|
+
pending: {
|
|
185
|
+
where: 'kind=task and status=pending',
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* @returns {{ original_working_directory: string, project_directory: string | null }}
|
|
193
|
+
*/
|
|
194
|
+
function createTestContext() {
|
|
195
|
+
return {
|
|
196
|
+
original_working_directory: process.cwd(),
|
|
197
|
+
project_directory: null,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @param {{ original_working_directory: string, project_directory: string | null }} test_context
|
|
203
|
+
*/
|
|
204
|
+
async function cleanupTestContext(test_context) {
|
|
205
|
+
process.chdir(test_context.original_working_directory);
|
|
206
|
+
|
|
207
|
+
if (test_context.project_directory) {
|
|
208
|
+
await removeDirectory(test_context.project_directory);
|
|
209
|
+
test_context.project_directory = null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface StoredQueryConfig {
|
|
2
|
+
where: string;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export interface PatramRepoConfig {
|
|
6
|
+
include: string[];
|
|
7
|
+
queries: Record<string, StoredQueryConfig>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface PatramDiagnostic {
|
|
11
|
+
code: string;
|
|
12
|
+
column: number;
|
|
13
|
+
level: 'error';
|
|
14
|
+
line: number;
|
|
15
|
+
message: string;
|
|
16
|
+
path: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LoadPatramConfigResult {
|
|
20
|
+
config: PatramRepoConfig | null;
|
|
21
|
+
config_path: string;
|
|
22
|
+
diagnostics: PatramDiagnostic[];
|
|
23
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { PatramClaim, ParseClaimsInput, PatramClaimFields } from './parse-claims.types.ts';
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const MARKDOWN_EXTENSIONS = new Set(['.md', '.markdown']);
|
|
6
|
+
const MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/dgu;
|
|
7
|
+
const DIRECTIVE_PATTERN = /^([A-Z][A-Za-z ]+):\s+(.+)$/du;
|
|
8
|
+
const HEADING_PATTERN = /^#\s+(.+)$/du;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse a file into neutral Patram claims.
|
|
12
|
+
*
|
|
13
|
+
* @param {ParseClaimsInput} parse_input
|
|
14
|
+
* @returns {PatramClaim[]}
|
|
15
|
+
*/
|
|
16
|
+
export function parseClaims(parse_input) {
|
|
17
|
+
const file_extension = getFileExtension(parse_input.path);
|
|
18
|
+
|
|
19
|
+
if (MARKDOWN_EXTENSIONS.has(file_extension)) {
|
|
20
|
+
return parseMarkdownClaims(parse_input);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {ParseClaimsInput} parse_input
|
|
28
|
+
* @returns {PatramClaim[]}
|
|
29
|
+
*/
|
|
30
|
+
function parseMarkdownClaims(parse_input) {
|
|
31
|
+
const lines = parse_input.source.split('\n');
|
|
32
|
+
|
|
33
|
+
/** @type {PatramClaim[]} */
|
|
34
|
+
const claims = [];
|
|
35
|
+
const title_value = getMarkdownTitle(lines);
|
|
36
|
+
|
|
37
|
+
if (title_value) {
|
|
38
|
+
claims.push(
|
|
39
|
+
createClaim(parse_input.path, claims.length + 1, 'document.title', {
|
|
40
|
+
value: title_value,
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const [line_index, line] of lines.entries()) {
|
|
46
|
+
const line_number = line_index + 1;
|
|
47
|
+
|
|
48
|
+
collectMarkdownLinkClaims(parse_input.path, line, line_number, claims);
|
|
49
|
+
collectDirectiveClaims(parse_input.path, line, line_number, claims);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return claims;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {string} file_path
|
|
57
|
+
* @param {string} line
|
|
58
|
+
* @param {number} line_number
|
|
59
|
+
* @param {PatramClaim[]} claims
|
|
60
|
+
*/
|
|
61
|
+
function collectMarkdownLinkClaims(file_path, line, line_number, claims) {
|
|
62
|
+
for (const link_match of line.matchAll(MARKDOWN_LINK_PATTERN)) {
|
|
63
|
+
const link_text = link_match[1];
|
|
64
|
+
const target_value = link_match[2];
|
|
65
|
+
const column_number =
|
|
66
|
+
link_match.index === undefined ? 1 : link_match.index + 1;
|
|
67
|
+
|
|
68
|
+
claims.push(
|
|
69
|
+
createClaim(file_path, claims.length + 1, 'markdown.link', {
|
|
70
|
+
origin: {
|
|
71
|
+
column: column_number,
|
|
72
|
+
line: line_number,
|
|
73
|
+
path: file_path,
|
|
74
|
+
},
|
|
75
|
+
value: {
|
|
76
|
+
target: target_value,
|
|
77
|
+
text: link_text,
|
|
78
|
+
},
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {string} file_path
|
|
86
|
+
* @param {string} line
|
|
87
|
+
* @param {number} line_number
|
|
88
|
+
* @param {PatramClaim[]} claims
|
|
89
|
+
*/
|
|
90
|
+
function collectDirectiveClaims(file_path, line, line_number, claims) {
|
|
91
|
+
const directive_match = line.match(DIRECTIVE_PATTERN);
|
|
92
|
+
|
|
93
|
+
if (!directive_match) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const directive_name = normalizeDirectiveName(directive_match[1]);
|
|
98
|
+
const directive_value = directive_match[2].trim();
|
|
99
|
+
|
|
100
|
+
claims.push(
|
|
101
|
+
createClaim(file_path, claims.length + 1, 'directive', {
|
|
102
|
+
name: directive_name,
|
|
103
|
+
origin: {
|
|
104
|
+
column: 1,
|
|
105
|
+
line: line_number,
|
|
106
|
+
path: file_path,
|
|
107
|
+
},
|
|
108
|
+
parser: 'markdown',
|
|
109
|
+
value: directive_value,
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @param {string[]} lines
|
|
116
|
+
* @returns {string | null}
|
|
117
|
+
*/
|
|
118
|
+
function getMarkdownTitle(lines) {
|
|
119
|
+
const first_line = lines[0].trim();
|
|
120
|
+
|
|
121
|
+
if (first_line.length === 0) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const heading_match = first_line.match(HEADING_PATTERN);
|
|
126
|
+
|
|
127
|
+
if (heading_match) {
|
|
128
|
+
return heading_match[1].trim();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return first_line;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {string} file_path
|
|
136
|
+
* @param {number} claim_number
|
|
137
|
+
* @param {string} claim_type
|
|
138
|
+
* @param {PatramClaimFields} claim_fields
|
|
139
|
+
* @returns {PatramClaim}
|
|
140
|
+
*/
|
|
141
|
+
function createClaim(file_path, claim_number, claim_type, claim_fields) {
|
|
142
|
+
const document_id = `doc:${file_path}`;
|
|
143
|
+
const origin = claim_fields.origin ?? {
|
|
144
|
+
column: 1,
|
|
145
|
+
line: 1,
|
|
146
|
+
path: file_path,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
...claim_fields,
|
|
151
|
+
document_id,
|
|
152
|
+
id: `claim:${document_id}:${claim_number}`,
|
|
153
|
+
origin,
|
|
154
|
+
type: claim_type,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* @param {string} directive_label
|
|
160
|
+
* @returns {string}
|
|
161
|
+
*/
|
|
162
|
+
function normalizeDirectiveName(directive_label) {
|
|
163
|
+
return directive_label.trim().toLowerCase().replaceAll(/\s+/dgu, '_');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* @param {string} file_path
|
|
168
|
+
* @returns {string}
|
|
169
|
+
*/
|
|
170
|
+
function getFileExtension(file_path) {
|
|
171
|
+
const last_dot_index = file_path.lastIndexOf('.');
|
|
172
|
+
|
|
173
|
+
if (last_dot_index < 0) {
|
|
174
|
+
return '';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return file_path.slice(last_dot_index);
|
|
178
|
+
}
|