diff --git a/__tests__/yaml-key-sort.test.ts b/__tests__/yaml-key-sort.test.ts index 7a75001d..3b7b1f02 100644 --- a/__tests__/yaml-key-sort.test.ts +++ b/__tests__/yaml-key-sort.test.ts @@ -165,16 +165,16 @@ ruleTest({ `, after: dedent` --- - related-companies:${' '} - stakeholders:${' '} - pr-pipeline-stage:${' '} - pr-priority:${' '} - pr-size:${' '} - pr-urgency:${' '} - pr-type:${' '} - pr-okr:${' '} - pr-due-date:${' '} - pr-completed-date:${' '} + related-companies: + stakeholders: + pr-pipeline-stage: + pr-priority: + pr-size: + pr-urgency: + pr-type: + pr-okr: + pr-due-date: + pr-completed-date: template: "[[Pro New Project Outcome Template]]" created-date: '[[<% tp.file.creation_date("YYYY-MM-DD") %>]]' modified: Tuesday, October 22nd 2024, 10:58:16 am @@ -199,5 +199,110 @@ ruleTest({ ], }, }, + { // accounts for https://github.com/platers/obsidian-linter/issues/1082 + testName: 'Make sure that literal operator `|` is handled correctly', + before: dedent` + --- + sorting-spec: | + order-desc: a-z + first: alphabetical + --- + `, + after: dedent` + --- + first: alphabetical + sorting-spec: | + order-desc: a-z + --- + `, + options: { + yamlKeyPrioritySortOrder: [ + 'first', + 'sorting-spec:', + ], + }, + }, + { // accounts for https://github.com/platers/obsidian-linter/issues/912 + testName: 'Make sure that lots of nesting does not get broken', + before: dedent` + --- + AAA: + - BBB: x + CCC: x + - DDD: x + EEE: x + FFF: + - GGG: x + - HHH + III: x + FFF: + -${' '} + --- + `, + after: dedent` + --- + AAA: + - BBB: x + CCC: x + - DDD: x + EEE: x + FFF: + - GGG: x + - HHH + III: x + FFF: + ${' '} + -${' '} + --- + `, // note that this does look wonky, but it works the same in Obsidian, so I am going to consider this fine for now + // see https://github.com/eemeli/yaml/issues/590 for more information on this + options: { + yamlSortOrderForOtherKeys: 'Ascending Alphabetical', + }, + }, + { // accounts for https://github.com/platers/obsidian-linter/issues/660 + testName: 'Make sure that comments are not removed when sorting', + before: dedent` + --- + created: 2023-02-18T10:53:01+00:00 + # Dont remove me + disabled rules: [capitalize-headings] + modified: 2023-03-20T14:53:42+00:00 + --- + `, + after: dedent` + --- + created: 2023-02-18T10:53:01+00:00 + # Dont remove me + disabled rules: [ capitalize-headings ] + modified: 2023-03-20T14:53:42+00:00 + --- + `, + options: { + yamlSortOrderForOtherKeys: 'Ascending Alphabetical', + }, + }, + { // accounts for https://github.com/platers/obsidian-linter/issues/660 + testName: 'Make sure that scalars are not removed when sorting', + before: dedent` + --- + name: John + husband: Tim + biography: | + A very nice chap. A very nice chap. A very nice chap. A very nice chap. A very nice chap. A very nice chap. A very nice chap. A very nice chap. + --- + `, + after: dedent` + --- + biography: | + A very nice chap. A very nice chap. A very nice chap. A very nice chap. A very nice chap. A very nice chap. A very nice chap. A very nice chap. + husband: Tim + name: John + --- + `, + options: { + yamlSortOrderForOtherKeys: 'Ascending Alphabetical', + }, + }, ], }); diff --git a/package-lock.json b/package-lock.json index f6f277dd..940d88c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,17 @@ { "name": "obsidian-linter", - "version": "1.27.0-rc-1", + "version": "1.27.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "obsidian-linter", - "version": "1.27.0-rc-1", + "version": "1.27.1", "license": "MIT", "dependencies": { "@popperjs/core": "^2.11.6", "async-lock": "^1.4.1", "diff-match-patch": "^1.0.5", - "js-yaml": "^4.1.0", "loglevel": "^1.9.1", "mdast-util-from-markdown": "^2.0.0", "mdast-util-frontmatter": "^2.0.1", @@ -26,7 +25,8 @@ "moment-parseformat": "^4.0.0", "quick-lru": "^7.0.0", "ts-dedent": "^2.2.0", - "unist-util-visit": "^5.0.0" + "unist-util-visit": "^5.0.0", + "yaml": "^2.6.0" }, "devDependencies": { "@babel/core": "^7.24.0", @@ -41,7 +41,6 @@ "@types/diff": "^5.0.9", "@types/diff-match-patch": "^1.0.32", "@types/jest": "^29.5.12", - "@types/js-yaml": "^4.0.5", "@types/node": "^20.11.28", "@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/parser": "^7.2.0", @@ -3408,12 +3407,6 @@ "pretty-format": "^29.0.0" } }, - "node_modules/@types/js-yaml": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", - "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", - "dev": true - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4306,7 +4299,8 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true }, "node_modules/array-union": { "version": "2.1.0", @@ -8500,6 +8494,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, "dependencies": { "argparse": "^2.0.1" }, @@ -11760,10 +11755,9 @@ "dev": true }, "node_modules/yaml": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", - "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==", - "dev": true, + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz", + "integrity": "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==", "bin": { "yaml": "bin.mjs" }, @@ -14162,12 +14156,6 @@ "pretty-format": "^29.0.0" } }, - "@types/js-yaml": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", - "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", - "dev": true - }, "@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -14769,7 +14757,8 @@ "argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true }, "array-union": { "version": "2.1.0", @@ -17803,6 +17792,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, "requires": { "argparse": "^2.0.1" } @@ -20021,10 +20011,9 @@ "dev": true }, "yaml": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", - "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==", - "dev": true + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz", + "integrity": "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==" }, "yargs": { "version": "17.7.2", diff --git a/package.json b/package.json index c58f7263..b7de6537 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,6 @@ "@types/diff": "^5.0.9", "@types/diff-match-patch": "^1.0.32", "@types/jest": "^29.5.12", - "@types/js-yaml": "^4.0.5", "@types/node": "^20.11.28", "@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/parser": "^7.2.0", @@ -61,7 +60,6 @@ "@popperjs/core": "^2.11.6", "async-lock": "^1.4.1", "diff-match-patch": "^1.0.5", - "js-yaml": "^4.1.0", "loglevel": "^1.9.1", "mdast-util-from-markdown": "^2.0.0", "mdast-util-frontmatter": "^2.0.1", @@ -75,6 +73,7 @@ "moment-parseformat": "^4.0.0", "quick-lru": "^7.0.0", "ts-dedent": "^2.2.0", - "unist-util-visit": "^5.0.0" + "unist-util-visit": "^5.0.0", + "yaml": "^2.6.0" } } diff --git a/src/rules/yaml-key-sort.ts b/src/rules/yaml-key-sort.ts index 345e0bf9..417afb58 100644 --- a/src/rules/yaml-key-sort.ts +++ b/src/rules/yaml-key-sort.ts @@ -1,7 +1,9 @@ import {Options, RuleType} from '../rules'; import RuleBuilder, {BooleanOptionBuilder, DropdownOptionBuilder, ExampleBuilder, OptionBuilderBase, TextAreaOptionBuilder} from './rule-builder'; import dedent from 'ts-dedent'; -import {getYAMLText, getYamlSectionValue, loadYAML, removeYamlSection, setYamlSection} from '../utils/yaml'; +import {parseYAML, getYAMLText, loadYAML, setYamlSection, astToString} from '../utils/yaml'; +import {Document, YAMLMap} from 'yaml'; +import {YamlNode} from '../typings/yaml'; type YamlSortOrderForOtherKeys = 'None' | 'Ascending Alphabetical' | 'Descending Alphabetical'; @@ -40,7 +42,7 @@ export default class YamlKeySort extends RuleBuilder { return text; } - let yamlText = oldYaml; + const yamlText = oldYaml; const priorityAtStartOfYaml: boolean = options.priorityKeysAtStartOfYaml; const yamlKeys: string[] = options.yamlKeyPrioritySortOrder; @@ -57,55 +59,54 @@ export default class YamlKeySort extends RuleBuilder { } const yamlObject = loadYAML(yamlText); - const sortKeysResult = this.getYAMLKeysSorted(yamlText, yamlKeys, yamlObject); - const priorityKeysSorted = sortKeysResult.sortedYamlKeyValues; - yamlText = sortKeysResult.remainingYaml; + const doc = parseYAML(yamlText); + const startingPriorityKeys = new Document(yamlObject.options); + startingPriorityKeys.contents = new YAMLMap(); + + let remainingKeys = this.getYAMLKeysSorted(yamlKeys, doc, startingPriorityKeys); const sortOrder = options.yamlSortOrderForOtherKeys; if (yamlObject == null) { - return this.getTextWithNewYamlFrontmatter(text, oldYaml, priorityKeysSorted, yamlText, priorityAtStartOfYaml, options.dateModifiedKey, options.currentTimeFormatted, options.yamlTimestampDateModifiedEnabled); + return this.getTextWithNewYamlFrontmatter(text, oldYaml, astToString(startingPriorityKeys), astToString(doc), priorityAtStartOfYaml, options.dateModifiedKey, options.currentTimeFormatted, options.yamlTimestampDateModifiedEnabled); } - let remainingKeys = Object.keys(yamlObject); let sortMethod: (previousKey: string, currentKey: string) => number; if (sortOrder === 'Ascending Alphabetical') { sortMethod = this.sortAlphabeticallyAsc; } else if (sortOrder === 'Descending Alphabetical') { sortMethod = this.sortAlphabeticallyDesc; } else { - return this.getTextWithNewYamlFrontmatter(text, oldYaml, priorityKeysSorted, yamlText, priorityAtStartOfYaml, options.dateModifiedKey, options.currentTimeFormatted, options.yamlTimestampDateModifiedEnabled); + return this.getTextWithNewYamlFrontmatter(text, oldYaml, astToString(startingPriorityKeys), astToString(doc), priorityAtStartOfYaml, options.dateModifiedKey, options.currentTimeFormatted, options.yamlTimestampDateModifiedEnabled); } + const remainingDocKeys = new Document(yamlObject.options); + remainingDocKeys.contents = new YAMLMap(); + remainingKeys = remainingKeys.sort(sortMethod); - const remainingKeysSortResult = this.getYAMLKeysSorted(yamlText, remainingKeys, yamlObject); + this.getYAMLKeysSorted(remainingKeys, doc, remainingDocKeys); - return this.getTextWithNewYamlFrontmatter(text, oldYaml, priorityKeysSorted, remainingKeysSortResult.sortedYamlKeyValues, priorityAtStartOfYaml, options.dateModifiedKey, options.currentTimeFormatted, options.yamlTimestampDateModifiedEnabled); + return this.getTextWithNewYamlFrontmatter(text, oldYaml, astToString(startingPriorityKeys), astToString(remainingDocKeys), priorityAtStartOfYaml, options.dateModifiedKey, options.currentTimeFormatted, options.yamlTimestampDateModifiedEnabled); } - getYAMLKeysSorted(yaml: string, keys: string[], yamlObject: any): {remainingYaml: string, sortedYamlKeyValues: string} { - let specifiedYamlKeysSorted = ''; - for (const key of keys) { - // we skip any nested elements when sorting to prevent issues where possible - if (!(key in yamlObject)) { - continue; - } - - const value = getYamlSectionValue(yaml, key, false); + getYAMLKeysSorted(keys: string[], yamlObject: Document, newDocument: Document): string[] { + const initialKeys: YamlNode[] = (yamlObject.contents as YamlNode).items as YamlNode[]; + const remainingKeys: string[] = []; - if (value !== null) { - if (value.includes('\n')) { - specifiedYamlKeysSorted += `${key}:${value}\n`; - } else { - specifiedYamlKeysSorted += `${key}: ${value}\n`; + for (const key of keys) { + for (let i = 0; i < initialKeys.length; i++) { + const node = initialKeys[i]; + if (node.key.value === key) { + newDocument.add(node); + initialKeys.splice(i, 1); + break; } - - yaml = removeYamlSection(yaml, key, false); } } - return { - remainingYaml: yaml, - sortedYamlKeyValues: specifiedYamlKeysSorted, - }; + for (const node of initialKeys) { + remainingKeys.push(node.key.value); + } + + return remainingKeys; } updateDateModifiedIfYamlChanged(oldYaml: string, newYaml: string, dateModifiedKey: string, currentTimeFormatted: string): string { if (oldYaml == newYaml) { @@ -247,16 +248,19 @@ export default class YamlKeySort extends RuleBuilder { status: WIP date: 02/15/2022 --- + Any blank line is attached to the line that follows it `, after: dedent` --- tags: computer + ${''} status: WIP keywords: [] date: 02/15/2022 type: programming language: Typescript --- + Any blank line is attached to the line that follows it `, options: { yamlKeyPrioritySortOrder: [ diff --git a/src/typings/obsidian-ex.d.ts b/src/typings/obsidian-ex.d.ts index 46a596fe..7b0ee3cc 100644 --- a/src/typings/obsidian-ex.d.ts +++ b/src/typings/obsidian-ex.d.ts @@ -74,7 +74,7 @@ declare module 'obsidian' { * CodeMirror editor instance */ cm?: EditorView; -} + } } diff --git a/src/typings/yaml.d.ts b/src/typings/yaml.d.ts new file mode 100644 index 00000000..fc877efb --- /dev/null +++ b/src/typings/yaml.d.ts @@ -0,0 +1,24 @@ +import {QuoteCharacter} from '../utils/yaml'; + +interface YamlProperties { + lineWidth: number, + quotingType: QuoteCharacter, + forceQuotes: boolean, +} + +interface Key { + value?: string; +} + +interface YamlNode { + constructor: { name: string }; + key?: Key; + value?: any; + items?: [string, any][]; + moved?: boolean; +} + +declare module 'yaml' { + // for now we really just need this for use with strings, so we shall work with that; + export function stringify(value: string, properties?: YamlProperties): string; +} diff --git a/src/utils/yaml.ts b/src/utils/yaml.ts index bf55c190..e54fa8e4 100644 --- a/src/utils/yaml.ts +++ b/src/utils/yaml.ts @@ -1,7 +1,8 @@ -import {load, dump} from 'js-yaml'; import {getTextInLanguage} from '../lang/helpers'; import {escapeDollarSigns, yamlRegex} from './regex'; import {isNumeric} from './strings'; +import {parse, parseDocument, Document, stringify} from 'yaml'; +import {YamlNode} from 'src/typings/yaml'; export const OBSIDIAN_TAG_KEY_SINGULAR = 'tag'; @@ -93,7 +94,7 @@ export function loadYAML(yaml_text: string): any { // replacing tabs at the beginning of new lines with 2 spaces fixes loading YAML that has tabs at the start of a line // https://github.com/platers/obsidian-linter/issues/157 - const parsed_yaml = load(yaml_text.replace(/\n(\t)+/g, '\n ')) as {}; + const parsed_yaml = parse(yaml_text.replace(/\n(\t)+/g, '\n ')) as {}; if (parsed_yaml == null) { return {}; } @@ -101,6 +102,34 @@ export function loadYAML(yaml_text: string): any { return parsed_yaml; } +export function parseYAML(yaml_text: string): Document { + if (yaml_text == null) { + return null; + } + + // replacing tabs at the beginning of new lines with 2 spaces fixes loading YAML that has tabs at the start of a line + // https://github.com/platers/obsidian-linter/issues/157 + const parsed_yaml = parseDocument(yaml_text.replace(/\n(\t)+/g, '\n ')); + if (parsed_yaml == null) { + return null; + } + + return parsed_yaml; +} + +export function astToString(ast: Document): string { + if (!ast || !ast.contents) { + return ''; + } + + const items = (ast.contents as YamlNode).items as YamlNode[]; + if (!items || items.length == 0) { + return ''; + } + + return ast.toString(); +} + export enum TagSpecificArrayFormats { SingleStringSpaceDelimited = 'single string space delimited', SingleLineSpaceDelimited = 'single-line space delimited', @@ -382,7 +411,7 @@ export function escapeStringIfNecessaryAndPossible(value: string, defaultEscapeC } try { - const unescaped = load(basicEscape) as string; + const unescaped = parse(basicEscape) as string; if (unescaped === value) { return basicEscape; } @@ -390,13 +419,13 @@ export function escapeStringIfNecessaryAndPossible(value: string, defaultEscapeC // invalid YAML } - const escapeWithDefaultCharacter = dump(value, { + const escapeWithDefaultCharacter = stringify(value, { lineWidth: -1, quotingType: defaultEscapeCharacter, forceQuotes: forceEscape, }).slice(0, -1); - const escapeWithOtherCharacter = dump(value, { + const escapeWithOtherCharacter = stringify(value, { lineWidth: -1, quotingType: defaultEscapeCharacter == '"' ? '\'' : '"', forceQuotes: forceEscape,