@getrefino/core 0.1.0-rc.5 → 0.1.0

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.
@@ -0,0 +1,152 @@
1
+ import { isDocumentId } from "../document.js";
2
+ import { ContentError } from "../errors.js";
3
+ export const SUPPORTED_EXTENSIONS = [".md", ".mdx"];
4
+ export const MAX_CONTENT_SOURCES = 16;
5
+ export const EMPTY_CONTENT_CONFIG = { sources: [] };
6
+ const SOURCE_ID = /^[A-Za-z0-9_-]+$/;
7
+ const PATH_SEGMENT = /^[A-Za-z0-9_-]+$/;
8
+ function configError(message) {
9
+ return new ContentError("NOT_CONFIGURED", `refino.config.json: ${message}`);
10
+ }
11
+ function validateDir(value, sourceId) {
12
+ if (typeof value !== "string" || value.trim().length === 0) {
13
+ throw configError(`content source "${sourceId}" needs a "dir".`);
14
+ }
15
+ const dir = value.trim().replace(/\/+$/, "");
16
+ if (dir.startsWith("/") || dir.includes("\\") || dir.includes("\0")) {
17
+ throw configError(`content source "${sourceId}" has a "dir" that is not repository-relative.`);
18
+ }
19
+ const segments = dir.split("/");
20
+ if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
21
+ throw configError(`content source "${sourceId}" has a "dir" with invalid segments.`);
22
+ }
23
+ return dir;
24
+ }
25
+ function validateExtensions(value, sourceId) {
26
+ if (value === undefined)
27
+ return SUPPORTED_EXTENSIONS;
28
+ if (!Array.isArray(value) || value.length === 0) {
29
+ throw configError(`content source "${sourceId}" has an empty "extensions".`);
30
+ }
31
+ const extensions = [];
32
+ for (const entry of value) {
33
+ if (typeof entry !== "string" || !SUPPORTED_EXTENSIONS.includes(entry)) {
34
+ throw configError(`content source "${sourceId}" lists extension ${JSON.stringify(entry)}; Refino edits ${SUPPORTED_EXTENSIONS.join(" and ")}.`);
35
+ }
36
+ if (!extensions.includes(entry))
37
+ extensions.push(entry);
38
+ }
39
+ return extensions;
40
+ }
41
+ function validateFields(value, sourceId) {
42
+ if (value === undefined)
43
+ return {};
44
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
45
+ throw configError(`content source "${sourceId}" has a "fields" that is not an object.`);
46
+ }
47
+ const fields = {};
48
+ for (const [field, key] of Object.entries(value)) {
49
+ if (typeof key !== "string" || key.length === 0) {
50
+ throw configError(`content source "${sourceId}" maps "${field}" to something that is not a frontmatter key.`);
51
+ }
52
+ fields[field] = key;
53
+ }
54
+ return fields;
55
+ }
56
+ /** Read and validate the `content` block. An absent block means no articles, not an error. */
57
+ export function parseContentConfig(value) {
58
+ if (value === undefined || value === null)
59
+ return EMPTY_CONTENT_CONFIG;
60
+ if (typeof value !== "object" || Array.isArray(value)) {
61
+ throw configError('"content" must be an object with a "sources" array.');
62
+ }
63
+ const rawSources = value.sources;
64
+ if (rawSources === undefined)
65
+ return EMPTY_CONTENT_CONFIG;
66
+ if (!Array.isArray(rawSources))
67
+ throw configError('"content.sources" must be an array.');
68
+ if (rawSources.length > MAX_CONTENT_SOURCES) {
69
+ throw configError(`"content.sources" lists more than ${MAX_CONTENT_SOURCES} sources.`);
70
+ }
71
+ const sources = [];
72
+ for (const raw of rawSources) {
73
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
74
+ throw configError('every entry in "content.sources" must be an object.');
75
+ }
76
+ const entry = raw;
77
+ const id = entry.id;
78
+ if (typeof id !== "string" || !SOURCE_ID.test(id)) {
79
+ throw configError(`content source id ${JSON.stringify(id)} must be letters, digits, "-" or "_".`);
80
+ }
81
+ if (sources.some((source) => source.id === id)) {
82
+ throw configError(`content source "${id}" is declared twice.`);
83
+ }
84
+ sources.push({
85
+ id,
86
+ dir: validateDir(entry.dir, id),
87
+ extensions: validateExtensions(entry.extensions, id),
88
+ fields: validateFields(entry.fields, id),
89
+ });
90
+ }
91
+ return { sources };
92
+ }
93
+ /**
94
+ * Move a configuration's directories from being relative to the config file
95
+ * to being relative to the repository root.
96
+ *
97
+ * Sites declare `dir` next to their own `refino.config.json`, which in a
98
+ * monorepo is not the repository root. The server reads the file from the
99
+ * repository, so it has to say where it found it.
100
+ */
101
+ export function rebaseContentConfig(config, baseDir) {
102
+ const base = baseDir.replace(/^\/+|\/+$/g, "");
103
+ if (base === "")
104
+ return config;
105
+ return { sources: config.sources.map((source) => ({ ...source, dir: `${base}/${source.dir}` })) };
106
+ }
107
+ export function sourceTypeForPath(path) {
108
+ return path.endsWith(".mdx") ? "mdx" : "markdown";
109
+ }
110
+ /**
111
+ * Every path a document id could resolve to, in the source's extension
112
+ * order. Returns an empty list when no configured source claims the id.
113
+ */
114
+ export function documentCandidates(config, documentId) {
115
+ if (!isDocumentId(documentId))
116
+ return [];
117
+ const [sourceId, ...segments] = documentId.split(".");
118
+ const source = config.sources.find((candidate) => candidate.id === sourceId);
119
+ if (!source || segments.length === 0)
120
+ return [];
121
+ if (!segments.every((segment) => PATH_SEGMENT.test(segment)))
122
+ return [];
123
+ const stem = `${source.dir}/${segments.join("/")}`;
124
+ return source.extensions.map((extension) => ({
125
+ documentId,
126
+ source,
127
+ path: `${stem}${extension}`,
128
+ sourceType: sourceTypeForPath(`${stem}${extension}`),
129
+ }));
130
+ }
131
+ /**
132
+ * The document id for a repository-relative source path, or null when no
133
+ * configured source claims it. This is how an integration turns the file it
134
+ * just rendered into the id it passes to the editor.
135
+ */
136
+ export function documentIdForPath(config, path) {
137
+ const normalized = path.replace(/^\.\//, "").replace(/\\/g, "/");
138
+ for (const source of config.sources) {
139
+ const prefix = `${source.dir}/`;
140
+ if (!normalized.startsWith(prefix))
141
+ continue;
142
+ const extension = source.extensions.find((candidate) => normalized.endsWith(candidate));
143
+ if (!extension)
144
+ continue;
145
+ const slug = normalized.slice(prefix.length, normalized.length - extension.length);
146
+ const segments = slug.split("/");
147
+ if (segments.length === 0 || !segments.every((segment) => PATH_SEGMENT.test(segment)))
148
+ continue;
149
+ return `${source.id}.${segments.join(".")}`;
150
+ }
151
+ return null;
152
+ }
@@ -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 {};