@getrefino/core 0.1.0-rc.5 → 0.1.0-rc.6
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/dist/api.d.ts +52 -3
- package/dist/api.js +136 -5
- package/dist/document.d.ts +110 -0
- package/dist/document.js +89 -0
- package/dist/documents/config.d.ts +58 -0
- package/dist/documents/config.js +152 -0
- package/dist/documents/fields.d.ts +44 -0
- package/dist/documents/fields.js +105 -0
- package/dist/documents/file-store.d.ts +45 -0
- package/dist/documents/file-store.js +1 -0
- package/dist/documents/frontmatter.d.ts +67 -0
- package/dist/documents/frontmatter.js +407 -0
- package/dist/documents/index.d.ts +19 -0
- package/dist/documents/index.js +13 -0
- package/dist/documents/markdown.d.ts +59 -0
- package/dist/documents/markdown.js +162 -0
- package/dist/documents/service.d.ts +33 -0
- package/dist/documents/service.js +199 -0
- package/dist/errors.d.ts +7 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/local-file.d.ts +12 -0
- package/dist/local-file.js +59 -0
- package/package.json +5 -1
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which frontmatter keys Refino offers as article metadata.
|
|
3
|
+
*
|
|
4
|
+
* The canonical names are Refino's; the aliases are what sites actually
|
|
5
|
+
* write. Matching is pragmatic and deliberately conservative: an alias is
|
|
6
|
+
* only accepted when it can mean one thing, and when two keys in the same
|
|
7
|
+
* article would map to the same field, neither is matched and the site is
|
|
8
|
+
* told to say which it meant. Keys Refino does not recognize are left
|
|
9
|
+
* completely alone — they are still in the file, Refino simply does not
|
|
10
|
+
* offer them.
|
|
11
|
+
*/
|
|
12
|
+
/** The metadata fields Content Editing V1 knows about. Order is the order the editor shows them in. */
|
|
13
|
+
export declare const METADATA_FIELDS: readonly ["title", "subtitle", "excerpt", "description", "seoTitle", "seoDescription", "author", "date", "ctaLabel", "ctaText"];
|
|
14
|
+
export type MetadataFieldName = (typeof METADATA_FIELDS)[number];
|
|
15
|
+
/** Human labels for the metadata panel. */
|
|
16
|
+
export declare const METADATA_LABELS: Readonly<Record<MetadataFieldName, string>>;
|
|
17
|
+
/**
|
|
18
|
+
* Frontmatter keys that mean each field. Compared after lowercasing and
|
|
19
|
+
* dropping `-` and `_`, so `seo_title`, `seoTitle` and `SEO-Title` are one
|
|
20
|
+
* alias.
|
|
21
|
+
*/
|
|
22
|
+
export declare const METADATA_ALIASES: Readonly<Record<MetadataFieldName, readonly string[]>>;
|
|
23
|
+
export declare function normalizeKey(key: string): string;
|
|
24
|
+
export interface MetadataMatch {
|
|
25
|
+
readonly field: MetadataFieldName;
|
|
26
|
+
/** The frontmatter key this field reads and writes. */
|
|
27
|
+
readonly key: string;
|
|
28
|
+
}
|
|
29
|
+
export interface MetadataMatchResult {
|
|
30
|
+
readonly matches: readonly MetadataMatch[];
|
|
31
|
+
/** Fields skipped because more than one key in the article could have been them. */
|
|
32
|
+
readonly ambiguous: readonly {
|
|
33
|
+
readonly field: MetadataFieldName;
|
|
34
|
+
readonly keys: readonly string[];
|
|
35
|
+
}[];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Match an article's frontmatter keys to metadata fields.
|
|
39
|
+
*
|
|
40
|
+
* `overrides` is the source's `fields` configuration: a canonical field name
|
|
41
|
+
* mapped to the exact key to use. An override is authoritative — it settles
|
|
42
|
+
* ambiguity and can name a key that is not an alias of anything.
|
|
43
|
+
*/
|
|
44
|
+
export declare function matchMetadataFields(keys: readonly string[], overrides?: Readonly<Record<string, string>>): MetadataMatchResult;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which frontmatter keys Refino offers as article metadata.
|
|
3
|
+
*
|
|
4
|
+
* The canonical names are Refino's; the aliases are what sites actually
|
|
5
|
+
* write. Matching is pragmatic and deliberately conservative: an alias is
|
|
6
|
+
* only accepted when it can mean one thing, and when two keys in the same
|
|
7
|
+
* article would map to the same field, neither is matched and the site is
|
|
8
|
+
* told to say which it meant. Keys Refino does not recognize are left
|
|
9
|
+
* completely alone — they are still in the file, Refino simply does not
|
|
10
|
+
* offer them.
|
|
11
|
+
*/
|
|
12
|
+
/** The metadata fields Content Editing V1 knows about. Order is the order the editor shows them in. */
|
|
13
|
+
export const METADATA_FIELDS = [
|
|
14
|
+
"title",
|
|
15
|
+
"subtitle",
|
|
16
|
+
"excerpt",
|
|
17
|
+
"description",
|
|
18
|
+
"seoTitle",
|
|
19
|
+
"seoDescription",
|
|
20
|
+
"author",
|
|
21
|
+
"date",
|
|
22
|
+
"ctaLabel",
|
|
23
|
+
"ctaText",
|
|
24
|
+
];
|
|
25
|
+
/** Human labels for the metadata panel. */
|
|
26
|
+
export const METADATA_LABELS = {
|
|
27
|
+
title: "Title",
|
|
28
|
+
subtitle: "Subtitle",
|
|
29
|
+
excerpt: "Excerpt",
|
|
30
|
+
description: "Description",
|
|
31
|
+
seoTitle: "SEO title",
|
|
32
|
+
seoDescription: "SEO description",
|
|
33
|
+
author: "Author",
|
|
34
|
+
date: "Date",
|
|
35
|
+
ctaLabel: "CTA label",
|
|
36
|
+
ctaText: "CTA text",
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Frontmatter keys that mean each field. Compared after lowercasing and
|
|
40
|
+
* dropping `-` and `_`, so `seo_title`, `seoTitle` and `SEO-Title` are one
|
|
41
|
+
* alias.
|
|
42
|
+
*/
|
|
43
|
+
export const METADATA_ALIASES = {
|
|
44
|
+
title: ["title", "heading", "headline"],
|
|
45
|
+
subtitle: ["subtitle", "subhead", "subheading", "tagline", "standfirst"],
|
|
46
|
+
excerpt: ["excerpt", "summary", "abstract", "blurb", "lede", "lead"],
|
|
47
|
+
description: ["description", "desc"],
|
|
48
|
+
seoTitle: ["seotitle", "metatitle", "ogtitle"],
|
|
49
|
+
seoDescription: ["seodescription", "metadescription", "ogdescription"],
|
|
50
|
+
author: ["author", "byline", "writtenby"],
|
|
51
|
+
date: ["date", "publishedat", "publishdate", "pubdate", "published", "publisheddate"],
|
|
52
|
+
ctaLabel: ["ctalabel", "ctabutton", "calltoactionlabel"],
|
|
53
|
+
ctaText: ["ctatext", "cta", "calltoaction"],
|
|
54
|
+
};
|
|
55
|
+
export function normalizeKey(key) {
|
|
56
|
+
return key.toLowerCase().replace(/[-_\s]/g, "");
|
|
57
|
+
}
|
|
58
|
+
const BY_ALIAS = (() => {
|
|
59
|
+
const map = new Map();
|
|
60
|
+
for (const field of METADATA_FIELDS) {
|
|
61
|
+
for (const alias of METADATA_ALIASES[field])
|
|
62
|
+
map.set(normalizeKey(alias), field);
|
|
63
|
+
}
|
|
64
|
+
return map;
|
|
65
|
+
})();
|
|
66
|
+
/**
|
|
67
|
+
* Match an article's frontmatter keys to metadata fields.
|
|
68
|
+
*
|
|
69
|
+
* `overrides` is the source's `fields` configuration: a canonical field name
|
|
70
|
+
* mapped to the exact key to use. An override is authoritative — it settles
|
|
71
|
+
* ambiguity and can name a key that is not an alias of anything.
|
|
72
|
+
*/
|
|
73
|
+
export function matchMetadataFields(keys, overrides = {}) {
|
|
74
|
+
const present = new Set(keys);
|
|
75
|
+
const claimed = new Map();
|
|
76
|
+
for (const key of keys) {
|
|
77
|
+
const field = BY_ALIAS.get(normalizeKey(key));
|
|
78
|
+
if (!field)
|
|
79
|
+
continue;
|
|
80
|
+
const existing = claimed.get(field);
|
|
81
|
+
if (existing)
|
|
82
|
+
existing.push(key);
|
|
83
|
+
else
|
|
84
|
+
claimed.set(field, [key]);
|
|
85
|
+
}
|
|
86
|
+
const matches = [];
|
|
87
|
+
const ambiguous = [];
|
|
88
|
+
for (const field of METADATA_FIELDS) {
|
|
89
|
+
const override = overrides[field];
|
|
90
|
+
if (override !== undefined) {
|
|
91
|
+
if (present.has(override))
|
|
92
|
+
matches.push({ field, key: override });
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const candidates = claimed.get(field);
|
|
96
|
+
if (!candidates || candidates.length === 0)
|
|
97
|
+
continue;
|
|
98
|
+
if (candidates.length > 1) {
|
|
99
|
+
ambiguous.push({ field, keys: candidates });
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
matches.push({ field, key: candidates[0] });
|
|
103
|
+
}
|
|
104
|
+
return { matches, ambiguous };
|
|
105
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The path-generic persistence seam.
|
|
3
|
+
*
|
|
4
|
+
* `ContentAdapter` is bound to one file — the copy file — at construction,
|
|
5
|
+
* which is exactly right for copy and no use at all for articles, of which
|
|
6
|
+
* there are many. A store reads and writes any allow-listed path in the same
|
|
7
|
+
* repository, with the same conflict rule: a write on a revision that has
|
|
8
|
+
* moved is refused, never merged.
|
|
9
|
+
*
|
|
10
|
+
* Two implementations ship: `createLocalFileStore` in
|
|
11
|
+
* `@getrefino/core/local-file` (development) and `createGitHubFileStore` in
|
|
12
|
+
* `@getrefino/github` (production). `createGitHubContentAdapter` is built on
|
|
13
|
+
* the same store, so copy and articles commit through one code path.
|
|
14
|
+
*/
|
|
15
|
+
import type { CommitInfo } from "../adapter.js";
|
|
16
|
+
export interface SourceFile {
|
|
17
|
+
/** The file exactly as stored. */
|
|
18
|
+
readonly text: string;
|
|
19
|
+
/** Opaque revision of these bytes: a git blob SHA, or a hash of the file. */
|
|
20
|
+
readonly revision: string;
|
|
21
|
+
}
|
|
22
|
+
export interface SourceFileWrite {
|
|
23
|
+
readonly path: string;
|
|
24
|
+
readonly text: string;
|
|
25
|
+
/** The revision the new text was derived from. A store must refuse a stale one. */
|
|
26
|
+
readonly baseRevision: string;
|
|
27
|
+
/** Commit message, for stores that make commits. */
|
|
28
|
+
readonly message: string;
|
|
29
|
+
}
|
|
30
|
+
export interface SourceFileWriteResult {
|
|
31
|
+
readonly revision: string;
|
|
32
|
+
readonly commit?: CommitInfo;
|
|
33
|
+
}
|
|
34
|
+
export interface SourceFileStore {
|
|
35
|
+
/** Human-readable name for logs, e.g. "github" or "local-file". */
|
|
36
|
+
readonly name: string;
|
|
37
|
+
/** Read a repository-relative path. Returns null when it does not exist. */
|
|
38
|
+
readFile(path: string): Promise<SourceFile | null>;
|
|
39
|
+
/**
|
|
40
|
+
* Write a repository-relative path.
|
|
41
|
+
* Must throw `ContentError` with code `CONFLICT` when the stored revision
|
|
42
|
+
* is not `baseRevision`.
|
|
43
|
+
*/
|
|
44
|
+
writeFile(write: SourceFileWrite): Promise<SourceFileWriteResult>;
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A span parser for YAML frontmatter.
|
|
3
|
+
*
|
|
4
|
+
* It does not build a document tree and it cannot serialize one. For each
|
|
5
|
+
* top-level entry it records where the key is, where its value is, and how
|
|
6
|
+
* that value is written. Reading a field is a slice; writing one replaces a
|
|
7
|
+
* single span and leaves every other byte of the file alone.
|
|
8
|
+
*
|
|
9
|
+
* That is the whole reason it exists: key order, unknown keys, comments,
|
|
10
|
+
* indentation, blank lines and quoting style are preserved because they are
|
|
11
|
+
* never in the code path. Anything this parser cannot represent honestly is
|
|
12
|
+
* reported as not editable rather than guessed at.
|
|
13
|
+
*
|
|
14
|
+
* The YAML subset understood here is the one blog frontmatter actually uses:
|
|
15
|
+
* top-level scalar entries in plain, single-quoted, double-quoted, literal
|
|
16
|
+
* (`|`) or folded (`>`) form.
|
|
17
|
+
*/
|
|
18
|
+
export type ScalarStyle = "plain" | "empty" | "single" | "double" | "literal" | "folded";
|
|
19
|
+
export interface FrontmatterEntry {
|
|
20
|
+
readonly key: string;
|
|
21
|
+
/** Offset of the first character of the entry's key line. */
|
|
22
|
+
readonly entryStart: number;
|
|
23
|
+
/** Offset just past the entry's last line, including its line terminator. */
|
|
24
|
+
readonly entryEnd: number;
|
|
25
|
+
/** The span a write replaces. */
|
|
26
|
+
readonly valueStart: number;
|
|
27
|
+
readonly valueEnd: number;
|
|
28
|
+
readonly style: ScalarStyle | "unsupported";
|
|
29
|
+
/** The decoded value, or null when the style is unsupported. */
|
|
30
|
+
readonly value: string | null;
|
|
31
|
+
/** Why this entry cannot be edited, when it cannot. */
|
|
32
|
+
readonly reason: string | null;
|
|
33
|
+
/** Literal and folded entries only: the header token and the content indent. */
|
|
34
|
+
readonly block: {
|
|
35
|
+
readonly header: string;
|
|
36
|
+
readonly indent: string;
|
|
37
|
+
} | null;
|
|
38
|
+
}
|
|
39
|
+
export interface FrontmatterBlock {
|
|
40
|
+
/** Offset of the opening fence. */
|
|
41
|
+
readonly start: number;
|
|
42
|
+
/** Offset just past the closing fence's line terminator. */
|
|
43
|
+
readonly end: number;
|
|
44
|
+
readonly entries: readonly FrontmatterEntry[];
|
|
45
|
+
}
|
|
46
|
+
interface Line {
|
|
47
|
+
readonly start: number;
|
|
48
|
+
/** End of the text, before the line terminator. */
|
|
49
|
+
readonly contentEnd: number;
|
|
50
|
+
/** End including the line terminator. */
|
|
51
|
+
readonly end: number;
|
|
52
|
+
readonly text: string;
|
|
53
|
+
}
|
|
54
|
+
export declare function splitLines(text: string): Line[];
|
|
55
|
+
export declare function encodeDouble(value: string): string;
|
|
56
|
+
export declare function encodeSingle(value: string): string;
|
|
57
|
+
/** Whether a value can be written as a plain scalar without changing what it means. */
|
|
58
|
+
export declare function isPlainSafe(value: string): boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Find and parse the leading frontmatter block. Returns null when the file
|
|
61
|
+
* does not open with one, which is not an error: a Markdown file without
|
|
62
|
+
* frontmatter simply has no metadata fields.
|
|
63
|
+
*/
|
|
64
|
+
export declare function parseFrontmatter(text: string): FrontmatterBlock | null;
|
|
65
|
+
/** The replacement text for one entry's value span. Throws when the style cannot carry the value. */
|
|
66
|
+
export declare function renderScalar(text: string, entry: FrontmatterEntry, value: string): string;
|
|
67
|
+
export {};
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A span parser for YAML frontmatter.
|
|
3
|
+
*
|
|
4
|
+
* It does not build a document tree and it cannot serialize one. For each
|
|
5
|
+
* top-level entry it records where the key is, where its value is, and how
|
|
6
|
+
* that value is written. Reading a field is a slice; writing one replaces a
|
|
7
|
+
* single span and leaves every other byte of the file alone.
|
|
8
|
+
*
|
|
9
|
+
* That is the whole reason it exists: key order, unknown keys, comments,
|
|
10
|
+
* indentation, blank lines and quoting style are preserved because they are
|
|
11
|
+
* never in the code path. Anything this parser cannot represent honestly is
|
|
12
|
+
* reported as not editable rather than guessed at.
|
|
13
|
+
*
|
|
14
|
+
* The YAML subset understood here is the one blog frontmatter actually uses:
|
|
15
|
+
* top-level scalar entries in plain, single-quoted, double-quoted, literal
|
|
16
|
+
* (`|`) or folded (`>`) form.
|
|
17
|
+
*/
|
|
18
|
+
export function splitLines(text) {
|
|
19
|
+
const lines = [];
|
|
20
|
+
let start = 0;
|
|
21
|
+
for (;;) {
|
|
22
|
+
const index = text.indexOf("\n", start);
|
|
23
|
+
if (index === -1) {
|
|
24
|
+
if (start < text.length || lines.length === 0) {
|
|
25
|
+
lines.push({ start, contentEnd: text.length, end: text.length, text: text.slice(start) });
|
|
26
|
+
}
|
|
27
|
+
return lines;
|
|
28
|
+
}
|
|
29
|
+
const contentEnd = index > start && text[index - 1] === "\r" ? index - 1 : index;
|
|
30
|
+
lines.push({ start, contentEnd, end: index + 1, text: text.slice(start, contentEnd) });
|
|
31
|
+
start = index + 1;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const FENCE = /^---[ \t]*$/;
|
|
35
|
+
const CLOSING_FENCE = /^(?:---|\.\.\.)[ \t]*$/;
|
|
36
|
+
/** A top-level entry: an unquoted key at column zero followed by a colon. */
|
|
37
|
+
const KEY_LINE = /^([A-Za-z0-9_][A-Za-z0-9_.-]*)[ \t]*:(?=[ \t]|$)/;
|
|
38
|
+
/** A block scalar header we can reproduce byte for byte. `|+` and `|2` are not on the list. */
|
|
39
|
+
const BLOCK_HEADER = /^([|>])(-?)$/;
|
|
40
|
+
const PLAIN_UNSAFE_FIRST = new Set([
|
|
41
|
+
"-", "?", ":", ",", "[", "]", "{", "}", "#", "&", "*", "!", "|", ">", "'", '"', "%", "@", "`",
|
|
42
|
+
]);
|
|
43
|
+
/** Anything below a space that is not a tab, and DEL. A regex of these is both unreadable and lint-hostile. */
|
|
44
|
+
function hasControlCharacters(value) {
|
|
45
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
46
|
+
const code = value.charCodeAt(index);
|
|
47
|
+
if (code === 0x09)
|
|
48
|
+
continue;
|
|
49
|
+
if (code < 0x20 || code === 0x7f)
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
function isBlank(text) {
|
|
55
|
+
return text.trim().length === 0;
|
|
56
|
+
}
|
|
57
|
+
function isIndented(text) {
|
|
58
|
+
return text.length > 0 && (text[0] === " " || text[0] === "\t");
|
|
59
|
+
}
|
|
60
|
+
function indentOf(text) {
|
|
61
|
+
return /^[ \t]*/.exec(text)[0];
|
|
62
|
+
}
|
|
63
|
+
function plainTrim(value) {
|
|
64
|
+
return value.replace(/[ \t]+$/, "");
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Where a plain scalar ends on its line: before a ` #` comment, and before
|
|
68
|
+
* any trailing whitespace. YAML only starts a comment when the `#` is
|
|
69
|
+
* preceded by whitespace, so `a#b` is one scalar.
|
|
70
|
+
*/
|
|
71
|
+
function plainScalarEnd(text, from, limit) {
|
|
72
|
+
for (let index = from + 1; index < limit; index += 1) {
|
|
73
|
+
if (text[index] !== "#")
|
|
74
|
+
continue;
|
|
75
|
+
const previous = text[index - 1];
|
|
76
|
+
if (previous !== " " && previous !== "\t")
|
|
77
|
+
continue;
|
|
78
|
+
let end = index - 1;
|
|
79
|
+
while (end > from && (text[end - 1] === " " || text[end - 1] === "\t"))
|
|
80
|
+
end -= 1;
|
|
81
|
+
return end;
|
|
82
|
+
}
|
|
83
|
+
let end = limit;
|
|
84
|
+
while (end > from && (text[end - 1] === " " || text[end - 1] === "\t"))
|
|
85
|
+
end -= 1;
|
|
86
|
+
return end;
|
|
87
|
+
}
|
|
88
|
+
/** Read a quoted token starting at `from`. Returns its end offset, or -1 if unterminated. */
|
|
89
|
+
function quotedEnd(text, from, limit) {
|
|
90
|
+
const quote = text[from];
|
|
91
|
+
for (let index = from + 1; index < limit; index += 1) {
|
|
92
|
+
const char = text[index];
|
|
93
|
+
if (quote === "'") {
|
|
94
|
+
if (char !== "'")
|
|
95
|
+
continue;
|
|
96
|
+
if (text[index + 1] === "'") {
|
|
97
|
+
index += 1;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
return index + 1;
|
|
101
|
+
}
|
|
102
|
+
if (char === "\\") {
|
|
103
|
+
index += 1;
|
|
104
|
+
}
|
|
105
|
+
else if (char === '"') {
|
|
106
|
+
return index + 1;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return -1;
|
|
110
|
+
}
|
|
111
|
+
function decodeSingle(raw) {
|
|
112
|
+
return raw.slice(1, -1).replace(/''/g, "'");
|
|
113
|
+
}
|
|
114
|
+
function decodeDouble(raw) {
|
|
115
|
+
let out = "";
|
|
116
|
+
const body = raw.slice(1, -1);
|
|
117
|
+
for (let index = 0; index < body.length; index += 1) {
|
|
118
|
+
const char = body[index];
|
|
119
|
+
if (char !== "\\") {
|
|
120
|
+
out += char;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const next = body[index + 1];
|
|
124
|
+
index += 1;
|
|
125
|
+
switch (next) {
|
|
126
|
+
case "n":
|
|
127
|
+
out += "\n";
|
|
128
|
+
break;
|
|
129
|
+
case "t":
|
|
130
|
+
out += "\t";
|
|
131
|
+
break;
|
|
132
|
+
case "r":
|
|
133
|
+
out += "\r";
|
|
134
|
+
break;
|
|
135
|
+
case '"':
|
|
136
|
+
out += '"';
|
|
137
|
+
break;
|
|
138
|
+
case "\\":
|
|
139
|
+
out += "\\";
|
|
140
|
+
break;
|
|
141
|
+
case "/":
|
|
142
|
+
out += "/";
|
|
143
|
+
break;
|
|
144
|
+
case " ":
|
|
145
|
+
out += " ";
|
|
146
|
+
break;
|
|
147
|
+
case "u": {
|
|
148
|
+
const hex = body.slice(index + 1, index + 5);
|
|
149
|
+
if (!/^[0-9a-fA-F]{4}$/.test(hex))
|
|
150
|
+
return null;
|
|
151
|
+
out += String.fromCharCode(Number.parseInt(hex, 16));
|
|
152
|
+
index += 4;
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
default:
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
export function encodeDouble(value) {
|
|
162
|
+
let out = '"';
|
|
163
|
+
for (const char of value) {
|
|
164
|
+
const code = char.codePointAt(0);
|
|
165
|
+
if (char === '"')
|
|
166
|
+
out += '\\"';
|
|
167
|
+
else if (char === "\\")
|
|
168
|
+
out += "\\\\";
|
|
169
|
+
else if (char === "\n")
|
|
170
|
+
out += "\\n";
|
|
171
|
+
else if (char === "\t")
|
|
172
|
+
out += "\\t";
|
|
173
|
+
else if (char === "\r")
|
|
174
|
+
out += "\\r";
|
|
175
|
+
else if (code < 0x20 || code === 0x7f)
|
|
176
|
+
out += `\\u${code.toString(16).padStart(4, "0")}`;
|
|
177
|
+
else
|
|
178
|
+
out += char;
|
|
179
|
+
}
|
|
180
|
+
return `${out}"`;
|
|
181
|
+
}
|
|
182
|
+
export function encodeSingle(value) {
|
|
183
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
184
|
+
}
|
|
185
|
+
/** Whether a value can be written as a plain scalar without changing what it means. */
|
|
186
|
+
export function isPlainSafe(value) {
|
|
187
|
+
if (value.length === 0)
|
|
188
|
+
return false;
|
|
189
|
+
if (value !== value.trim())
|
|
190
|
+
return false;
|
|
191
|
+
if (/[\n\r\t]/.test(value))
|
|
192
|
+
return false;
|
|
193
|
+
if (PLAIN_UNSAFE_FIRST.has(value[0]))
|
|
194
|
+
return false;
|
|
195
|
+
if (value.includes(": ") || value.endsWith(":"))
|
|
196
|
+
return false;
|
|
197
|
+
if (/[ \t]#/.test(value))
|
|
198
|
+
return false;
|
|
199
|
+
return !hasControlCharacters(value);
|
|
200
|
+
}
|
|
201
|
+
function unsupported(key, entryStart, entryEnd, valueStart, valueEnd, reason) {
|
|
202
|
+
return { key, entryStart, entryEnd, valueStart, valueEnd, style: "unsupported", value: null, reason, block: null };
|
|
203
|
+
}
|
|
204
|
+
function readBlockScalar(lines, index, last, key, entryStart, entryEnd, colonEnd, header) {
|
|
205
|
+
const contentLines = lines.slice(index + 1, last + 1);
|
|
206
|
+
const headerEnd = lines[index].contentEnd;
|
|
207
|
+
if (contentLines.length === 0) {
|
|
208
|
+
return unsupported(key, entryStart, entryEnd, colonEnd, headerEnd, "the block scalar has no content");
|
|
209
|
+
}
|
|
210
|
+
const indent = indentOf(contentLines[0].text);
|
|
211
|
+
const valueEnd = lines[last].contentEnd;
|
|
212
|
+
if (indent.length === 0) {
|
|
213
|
+
return unsupported(key, entryStart, entryEnd, colonEnd, valueEnd, "the block scalar is not indented");
|
|
214
|
+
}
|
|
215
|
+
const parts = [];
|
|
216
|
+
for (const line of contentLines) {
|
|
217
|
+
if (isBlank(line.text)) {
|
|
218
|
+
parts.push("");
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (!line.text.startsWith(indent)) {
|
|
222
|
+
return unsupported(key, entryStart, entryEnd, colonEnd, valueEnd, "the block scalar's indentation is uneven");
|
|
223
|
+
}
|
|
224
|
+
parts.push(line.text.slice(indent.length));
|
|
225
|
+
}
|
|
226
|
+
if (header.startsWith("|")) {
|
|
227
|
+
return {
|
|
228
|
+
key, entryStart, entryEnd, valueStart: colonEnd, valueEnd,
|
|
229
|
+
style: "literal", value: parts.join("\n"), reason: null, block: { header, indent },
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
// Folded: lines join with a space, a blank line becomes a newline. A line
|
|
233
|
+
// indented further than the block keeps its own breaks, which this parser
|
|
234
|
+
// does not reproduce, so it is refused.
|
|
235
|
+
if (parts.some((part) => part.length > 0 && (part[0] === " " || part[0] === "\t"))) {
|
|
236
|
+
return unsupported(key, entryStart, entryEnd, colonEnd, valueEnd, "the folded value contains a more-indented line");
|
|
237
|
+
}
|
|
238
|
+
let folded = "";
|
|
239
|
+
for (const part of parts) {
|
|
240
|
+
if (part === "") {
|
|
241
|
+
folded += "\n";
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (folded.length > 0 && !folded.endsWith("\n"))
|
|
245
|
+
folded += " ";
|
|
246
|
+
folded += part;
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
key, entryStart, entryEnd, valueStart: colonEnd, valueEnd,
|
|
250
|
+
style: "folded", value: folded, reason: null, block: { header, indent },
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
function readEntry(text, lines, index, lastLine) {
|
|
254
|
+
const line = lines[index];
|
|
255
|
+
const match = KEY_LINE.exec(line.text);
|
|
256
|
+
const key = match[1];
|
|
257
|
+
const colonEnd = line.start + match[0].length;
|
|
258
|
+
// Continuation lines: indented lines, plus blank lines that are followed by
|
|
259
|
+
// another indented line. A comment at column zero belongs to no entry.
|
|
260
|
+
let last = index;
|
|
261
|
+
for (let scan = index + 1; scan <= lastLine; scan += 1) {
|
|
262
|
+
const candidate = lines[scan];
|
|
263
|
+
if (isBlank(candidate.text))
|
|
264
|
+
continue;
|
|
265
|
+
if (!isIndented(candidate.text))
|
|
266
|
+
break;
|
|
267
|
+
last = scan;
|
|
268
|
+
}
|
|
269
|
+
const entryStart = line.start;
|
|
270
|
+
const entryEnd = lines[last].end;
|
|
271
|
+
const next = last + 1;
|
|
272
|
+
const inlineRaw = text.slice(colonEnd, line.contentEnd);
|
|
273
|
+
const leading = indentOf(inlineRaw);
|
|
274
|
+
const inlineStart = colonEnd + leading.length;
|
|
275
|
+
const inline = inlineRaw.slice(leading.length);
|
|
276
|
+
const commentOnly = inline.startsWith("#");
|
|
277
|
+
const inlineText = commentOnly ? "" : inline;
|
|
278
|
+
if (BLOCK_HEADER.test(plainTrim(inlineText))) {
|
|
279
|
+
const header = plainTrim(inlineText);
|
|
280
|
+
return { entry: readBlockScalar(lines, index, last, key, entryStart, entryEnd, colonEnd, header), next };
|
|
281
|
+
}
|
|
282
|
+
if (last !== index) {
|
|
283
|
+
// Indented lines under something that is not a block scalar: a nested
|
|
284
|
+
// map, a sequence, or a multi-line scalar. Readable, not writable
|
|
285
|
+
// through a single span.
|
|
286
|
+
const reason = inlineText.length === 0
|
|
287
|
+
? "the value is a nested map or list, not a single value"
|
|
288
|
+
: "the value continues across several lines";
|
|
289
|
+
return { entry: unsupported(key, entryStart, entryEnd, inlineStart, line.contentEnd, reason), next };
|
|
290
|
+
}
|
|
291
|
+
if (inlineText.length === 0) {
|
|
292
|
+
const valueEnd = commentOnly ? inlineStart : line.contentEnd;
|
|
293
|
+
return {
|
|
294
|
+
entry: { key, entryStart, entryEnd, valueStart: inlineStart, valueEnd, style: "empty", value: "", reason: null, block: null },
|
|
295
|
+
next,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
const first = inlineText[0];
|
|
299
|
+
if (first === "'" || first === '"') {
|
|
300
|
+
const end = quotedEnd(text, inlineStart, line.contentEnd);
|
|
301
|
+
if (end === -1) {
|
|
302
|
+
return { entry: unsupported(key, entryStart, entryEnd, inlineStart, line.contentEnd, "the quoted value is not closed on the same line"), next };
|
|
303
|
+
}
|
|
304
|
+
const value = first === "'" ? decodeSingle(text.slice(inlineStart, end)) : decodeDouble(text.slice(inlineStart, end));
|
|
305
|
+
if (value === null) {
|
|
306
|
+
return { entry: unsupported(key, entryStart, entryEnd, inlineStart, end, "the quoted value uses an escape Refino does not reproduce"), next };
|
|
307
|
+
}
|
|
308
|
+
// Anything after the closing quote other than spaces or a comment is not
|
|
309
|
+
// something this parser understands.
|
|
310
|
+
const trailing = text.slice(end, line.contentEnd).trim();
|
|
311
|
+
if (trailing.length > 0 && !trailing.startsWith("#")) {
|
|
312
|
+
return { entry: unsupported(key, entryStart, entryEnd, inlineStart, end, "there is more after the quoted value than a comment"), next };
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
entry: {
|
|
316
|
+
key, entryStart, entryEnd, valueStart: inlineStart, valueEnd: end,
|
|
317
|
+
style: first === "'" ? "single" : "double", value, reason: null, block: null,
|
|
318
|
+
},
|
|
319
|
+
next,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
if (PLAIN_UNSAFE_FIRST.has(first)) {
|
|
323
|
+
return {
|
|
324
|
+
entry: unsupported(key, entryStart, entryEnd, inlineStart, line.contentEnd, `the value starts with "${first}", which Refino does not edit`),
|
|
325
|
+
next,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
const valueEnd = plainScalarEnd(text, inlineStart, line.contentEnd);
|
|
329
|
+
return {
|
|
330
|
+
entry: {
|
|
331
|
+
key, entryStart, entryEnd, valueStart: inlineStart, valueEnd,
|
|
332
|
+
style: "plain", value: text.slice(inlineStart, valueEnd), reason: null, block: null,
|
|
333
|
+
},
|
|
334
|
+
next,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Find and parse the leading frontmatter block. Returns null when the file
|
|
339
|
+
* does not open with one, which is not an error: a Markdown file without
|
|
340
|
+
* frontmatter simply has no metadata fields.
|
|
341
|
+
*/
|
|
342
|
+
export function parseFrontmatter(text) {
|
|
343
|
+
const lines = splitLines(text);
|
|
344
|
+
if (lines.length === 0)
|
|
345
|
+
return null;
|
|
346
|
+
const opening = lines[0];
|
|
347
|
+
if (!FENCE.test(opening.text))
|
|
348
|
+
return null;
|
|
349
|
+
let closing = -1;
|
|
350
|
+
for (let index = 1; index < lines.length; index += 1) {
|
|
351
|
+
if (CLOSING_FENCE.test(lines[index].text)) {
|
|
352
|
+
closing = index;
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (closing === -1)
|
|
357
|
+
return null;
|
|
358
|
+
const entries = [];
|
|
359
|
+
let index = 1;
|
|
360
|
+
while (index < closing) {
|
|
361
|
+
const line = lines[index];
|
|
362
|
+
if (isBlank(line.text) || line.text.trimStart().startsWith("#") || !KEY_LINE.test(line.text)) {
|
|
363
|
+
index += 1;
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
const read = readEntry(text, lines, index, closing - 1);
|
|
367
|
+
entries.push(read.entry);
|
|
368
|
+
index = Math.max(read.next, index + 1);
|
|
369
|
+
}
|
|
370
|
+
// A key written twice is ambiguous; neither copy is editable.
|
|
371
|
+
const counts = new Map();
|
|
372
|
+
for (const entry of entries)
|
|
373
|
+
counts.set(entry.key, (counts.get(entry.key) ?? 0) + 1);
|
|
374
|
+
const resolved = entries.map((entry) => (counts.get(entry.key) ?? 0) > 1
|
|
375
|
+
? unsupported(entry.key, entry.entryStart, entry.entryEnd, entry.valueStart, entry.valueEnd, `"${entry.key}" appears more than once in the frontmatter`)
|
|
376
|
+
: entry);
|
|
377
|
+
return { start: opening.start, end: lines[closing].end, entries: resolved };
|
|
378
|
+
}
|
|
379
|
+
/** The replacement text for one entry's value span. Throws when the style cannot carry the value. */
|
|
380
|
+
export function renderScalar(text, entry, value) {
|
|
381
|
+
switch (entry.style) {
|
|
382
|
+
case "plain":
|
|
383
|
+
case "empty": {
|
|
384
|
+
const rendered = isPlainSafe(value) ? value : encodeDouble(value);
|
|
385
|
+
// `key:` with nothing after it needs a space; `key: x` already has one.
|
|
386
|
+
return text[entry.valueStart - 1] === ":" ? ` ${rendered}` : rendered;
|
|
387
|
+
}
|
|
388
|
+
case "single":
|
|
389
|
+
return value.includes("\n") ? encodeDouble(value) : encodeSingle(value);
|
|
390
|
+
case "double":
|
|
391
|
+
return encodeDouble(value);
|
|
392
|
+
case "literal": {
|
|
393
|
+
const { header, indent } = entry.block;
|
|
394
|
+
const body = value.split("\n").map((line) => (line.length === 0 ? "" : indent + line)).join("\n");
|
|
395
|
+
return ` ${header}\n${body}`;
|
|
396
|
+
}
|
|
397
|
+
case "folded": {
|
|
398
|
+
const { header, indent } = entry.block;
|
|
399
|
+
if (value.includes("\n")) {
|
|
400
|
+
throw new Error(`"${entry.key}" is a folded block scalar and the new value contains a line break`);
|
|
401
|
+
}
|
|
402
|
+
return ` ${header}\n${indent}${value}`;
|
|
403
|
+
}
|
|
404
|
+
default:
|
|
405
|
+
throw new Error(`"${entry.key}" is not an editable frontmatter value`);
|
|
406
|
+
}
|
|
407
|
+
}
|