simple-task-master
Version:
A simple command-line task management tool
245 lines • 10 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.FrontmatterParser = void 0;
/**
* Custom frontmatter parser that preserves content exactly as written
* Replaces gray-matter to eliminate the need for _contentMetadata workarounds
*/
const js_yaml_1 = __importDefault(require("js-yaml"));
const errors_1 = require("./errors");
class FrontmatterParser {
static DELIMITER = '---';
static DELIMITER_REGEX = /^---\s*$/;
/**
* Parse markdown with frontmatter, preserving exact content formatting
*/
static parse(input) {
// Check for exact delimiter at start (no extra characters)
const firstLine = input.split('\n')[0];
if (!input.startsWith(this.DELIMITER) || !firstLine || !this.DELIMITER_REGEX.test(firstLine)) {
return {
data: {},
content: input,
matter: '',
orig: input
};
}
// Special handling for content attached to closing delimiter
// e.g., "---\nid: 1\n---Content" should be parsed correctly
const delimiterWithContent = new RegExp(`^${this.DELIMITER}(.+)$`, 'm');
const match = input.match(delimiterWithContent);
if (match && match.index && match.index > 0) {
// Found a delimiter with content attached
const beforeMatch = input.substring(0, match.index);
const attachedContent = match[1];
const afterMatch = input.substring(match.index + match[0].length);
// Check if this is a closing delimiter (not the opening one)
const beforeLines = beforeMatch.split('\n');
if (beforeLines.length > 1 && beforeLines[0] === this.DELIMITER) {
// This is a closing delimiter with attached content
const matter = beforeLines.slice(1).join('\n');
let data;
try {
const parsed = matter.trim() ? js_yaml_1.default.load(matter) : {};
// yaml.load returns null for comments-only content
const normalizedParsed = parsed === null ? {} : parsed;
data = this.convertDatesToStrings(normalizedParsed);
}
catch (error) {
throw new errors_1.ValidationError(`Invalid YAML frontmatter: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
// Combine attached content with any remaining content
const content = attachedContent + (afterMatch ? '\n' + afterMatch : '');
return {
data,
content,
matter,
orig: input
};
}
}
// Standard parsing logic
const lines = input.split('\n');
let endIndex = -1;
// Find closing delimiter
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
// Check if line starts with delimiter
if (line && line.startsWith(this.DELIMITER)) {
// Check if it's exactly the delimiter (with possible trailing spaces)
if (this.DELIMITER_REGEX.test(line)) {
endIndex = i;
break;
}
}
}
if (endIndex === -1) {
// Malformed frontmatter, treat as content
return {
data: {},
content: input,
matter: '',
orig: input
};
}
const matter = lines.slice(1, endIndex).join('\n');
let data;
try {
const parsed = matter.trim() ? js_yaml_1.default.load(matter) : {};
// yaml.load returns null for comments-only content
const normalizedParsed = parsed === null ? {} : parsed;
// Convert Date objects back to ISO strings to match expected format
data = this.convertDatesToStrings(normalizedParsed);
}
catch (error) {
throw new errors_1.ValidationError(`Invalid YAML frontmatter: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
// Preserve exact content including all whitespace
// Calculate the position after the closing delimiter
const frontmatterPart = lines.slice(0, endIndex + 1).join('\n');
let contentStartIndex = frontmatterPart.length;
// Only add 1 if there's actually a newline after the closing delimiter
if (input.length > contentStartIndex && input[contentStartIndex] === '\n') {
contentStartIndex += 1;
}
const content = input.substring(contentStartIndex);
return {
data,
content,
matter,
orig: input
};
}
/**
* Stringify content with frontmatter, preserving exact content formatting
*/
static stringify(content, data) {
if (!data || Object.keys(data).length === 0) {
return content;
}
let matter = js_yaml_1.default
.dump(data, {
lineWidth: -1,
noRefs: true,
sortKeys: false,
quotingType: '"',
forceQuotes: false,
condenseFlow: false
})
.trimEnd();
// Post-process to ensure strings with quotes are properly quoted
matter = this.postProcessYaml(matter);
return `${this.DELIMITER}\n${matter}\n${this.DELIMITER}\n${content}`;
}
/**
* Post-process YAML to ensure proper quoting
*/
static postProcessYaml(yamlStr) {
const lines = yamlStr.split('\n');
const processedLines = lines.map((line) => {
// Match YAML key-value pairs
const match = line.match(/^(\s*)([^:]+):\s*(.+)$/);
if (match) {
const [, indent, key, value] = match;
// Check if value contains quotes but isn't already quoted
if (value && value.includes('"') && !value.startsWith('"') && !value.startsWith('\'')) {
// Quote the value and escape internal quotes
const quotedValue = '"' + value.replace(/"/g, '\\"') + '"';
return `${indent}${key}: ${quotedValue}`;
}
}
return line;
});
return processedLines.join('\n');
}
/**
* Convert Date objects to ISO strings recursively
*/
static convertDatesToStrings(obj) {
if (obj instanceof Date) {
return obj.toISOString();
}
if (Array.isArray(obj)) {
return obj.map((item) => this.convertDatesToStrings(item));
}
if (obj && typeof obj === 'object') {
const result = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = this.convertDatesToStrings(value);
}
return result;
}
return obj;
}
/**
* Validate task data structure with detailed error messages
*/
static validateTaskData(data) {
if (!data || typeof data !== 'object') {
throw new errors_1.ValidationError('Invalid task data structure: expected object');
}
const task = data;
const missingFields = [];
// Check only the required fields: id, title, status
if (!('id' in task)) {
missingFields.push('id');
}
if (!('title' in task)) {
missingFields.push('title');
}
if (!('status' in task)) {
missingFields.push('status');
}
if (missingFields.length > 0) {
throw new errors_1.ValidationError(`missing required fields: ${missingFields.join(', ')}`);
}
// Validate field types only if they exist
const errors = [];
if ('id' in task && typeof task.id !== 'number') {
errors.push('id must be a number');
}
if ('title' in task && typeof task.title !== 'string') {
errors.push('title must be a string');
}
if ('status' in task &&
(typeof task.status !== 'string' || !['pending', 'in-progress', 'done'].includes(task.status))) {
errors.push('status must be one of: pending, in-progress, done');
}
// Optional fields - only validate if present
if ('schema' in task && typeof task.schema !== 'number') {
errors.push('schema must be a number');
}
if ('created' in task && typeof task.created !== 'string') {
errors.push('created must be an ISO date string');
}
if ('updated' in task && typeof task.updated !== 'string') {
errors.push('updated must be an ISO date string');
}
if ('tags' in task) {
if (!Array.isArray(task.tags)) {
errors.push('tags must be an array');
}
else if (!task.tags.every((tag) => typeof tag === 'string')) {
errors.push('tags must be an array of strings');
}
}
if ('dependencies' in task) {
if (!Array.isArray(task.dependencies)) {
errors.push('dependencies must be an array');
}
else if (!task.dependencies.every((dep) => typeof dep === 'number')) {
errors.push('dependencies must be an array of numbers');
}
}
if (errors.length > 0) {
throw new errors_1.ValidationError(`Invalid task data: ${errors.join(', ')}`);
}
}
}
exports.FrontmatterParser = FrontmatterParser;
// Export for drop-in replacement compatibility
exports.default = FrontmatterParser;
//# sourceMappingURL=frontmatter-parser.js.map