openalmanac 0.2.56 → 0.2.57
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/server.js +46 -1
- package/dist/tools/pages.js +15 -12
- package/dist/tools/topics.js +4 -2
- package/dist/tools/users.d.ts +2 -0
- package/dist/tools/users.js +13 -0
- package/dist/tools/wikis.js +57 -18
- package/dist/validate.d.ts +960 -0
- package/dist/validate.js +136 -113
- package/package.json +1 -1
package/dist/validate.js
CHANGED
|
@@ -1,8 +1,128 @@
|
|
|
1
1
|
import { parse as parseYaml } from "yaml";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const
|
|
5
|
-
const
|
|
2
|
+
import { z, ZodError } from "zod";
|
|
3
|
+
// ── Primitives ──────────────────────────────────────────────────
|
|
4
|
+
const sourceKeyRe = /^[a-z0-9]+-[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
5
|
+
const isoDateRe = /^\d{4}-\d{2}-\d{2}$/;
|
|
6
|
+
// YAML can deserialize `accessed_date: 2026-04-17` as a Date object or leave
|
|
7
|
+
// it as a string depending on quoting. Backend Pydantic coerces both to a
|
|
8
|
+
// `date`; we accept both shapes here.
|
|
9
|
+
const accessedDateSchema = z.union([
|
|
10
|
+
z.date(),
|
|
11
|
+
z.string().regex(isoDateRe, "Must be YYYY-MM-DD"),
|
|
12
|
+
]);
|
|
13
|
+
// ── Source — mirrors backend/src/schemas/source_schemas.py ──────
|
|
14
|
+
export const sourceSchema = z.object({
|
|
15
|
+
key: z
|
|
16
|
+
.string()
|
|
17
|
+
.min(3)
|
|
18
|
+
.regex(sourceKeyRe, "Must be kebab-case with at least one hyphen (e.g. 'nytimes-climate-report')"),
|
|
19
|
+
url: z
|
|
20
|
+
.string()
|
|
21
|
+
.min(1)
|
|
22
|
+
.regex(/^https?:\/\//, "Must start with http:// or https://"),
|
|
23
|
+
title: z.string().min(1).max(500),
|
|
24
|
+
accessed_date: accessedDateSchema,
|
|
25
|
+
}).strict();
|
|
26
|
+
// ── Infobox — mirrors backend/src/schemas/infobox_schemas.py ────
|
|
27
|
+
const keyValueItemSchema = z.object({
|
|
28
|
+
key: z.string(),
|
|
29
|
+
value: z.string(),
|
|
30
|
+
}).strict();
|
|
31
|
+
const headerBlockSchema = z.object({
|
|
32
|
+
image_url: z.string().nullable().optional(),
|
|
33
|
+
subtitle: z.string().nullable().optional(),
|
|
34
|
+
details: z.array(keyValueItemSchema).default([]),
|
|
35
|
+
// STRICTLY list[str] — no {url, label} objects. For labeled links use a
|
|
36
|
+
// `list` section with ListItem.link.
|
|
37
|
+
links: z.array(z.string()).default([]),
|
|
38
|
+
}).strict();
|
|
39
|
+
const timelineItemSchema = z.object({
|
|
40
|
+
primary: z.string(),
|
|
41
|
+
secondary: z.string().nullable().optional(),
|
|
42
|
+
period: z.string().nullable().optional(),
|
|
43
|
+
location: z.string().nullable().optional(),
|
|
44
|
+
description: z.array(z.string()).nullable().optional(),
|
|
45
|
+
link: z.string().nullable().optional(),
|
|
46
|
+
}).strict();
|
|
47
|
+
const listItemSchema = z.object({
|
|
48
|
+
title: z.string(),
|
|
49
|
+
subtitle: z.string().nullable().optional(),
|
|
50
|
+
year: z.string().nullable().optional(),
|
|
51
|
+
description: z.array(z.string()).nullable().optional(),
|
|
52
|
+
link: z.string().nullable().optional(),
|
|
53
|
+
}).strict();
|
|
54
|
+
const gridItemSchema = z.object({
|
|
55
|
+
title: z.string(),
|
|
56
|
+
image_url: z.string().nullable().optional(),
|
|
57
|
+
year: z.string().nullable().optional(),
|
|
58
|
+
description: z.string().nullable().optional(),
|
|
59
|
+
link: z.string().nullable().optional(),
|
|
60
|
+
type: z.string().nullable().optional(),
|
|
61
|
+
}).strict();
|
|
62
|
+
const tableRowSchema = z.object({
|
|
63
|
+
cells: z.array(z.string()),
|
|
64
|
+
}).strict();
|
|
65
|
+
const tableDataSchema = z.object({
|
|
66
|
+
headers: z.array(z.string()).min(1, "Table headers must be non-empty"),
|
|
67
|
+
rows: z.array(tableRowSchema),
|
|
68
|
+
}).strict();
|
|
69
|
+
const timelineSectionSchema = z.object({
|
|
70
|
+
type: z.literal("timeline"),
|
|
71
|
+
title: z.string(),
|
|
72
|
+
items: z.array(timelineItemSchema),
|
|
73
|
+
}).strict();
|
|
74
|
+
const listSectionSchema = z.object({
|
|
75
|
+
type: z.literal("list"),
|
|
76
|
+
title: z.string(),
|
|
77
|
+
items: z.array(listItemSchema),
|
|
78
|
+
}).strict();
|
|
79
|
+
const tagsSectionSchema = z.object({
|
|
80
|
+
type: z.literal("tags"),
|
|
81
|
+
title: z.string(),
|
|
82
|
+
items: z.array(z.string()),
|
|
83
|
+
}).strict();
|
|
84
|
+
const gridSectionSchema = z.object({
|
|
85
|
+
type: z.literal("grid"),
|
|
86
|
+
title: z.string(),
|
|
87
|
+
items: z.array(gridItemSchema),
|
|
88
|
+
}).strict();
|
|
89
|
+
const tableSectionSchema = z.object({
|
|
90
|
+
type: z.literal("table"),
|
|
91
|
+
title: z.string(),
|
|
92
|
+
items: tableDataSchema,
|
|
93
|
+
}).strict();
|
|
94
|
+
const keyValueSectionSchema = z.object({
|
|
95
|
+
type: z.literal("key_value"),
|
|
96
|
+
title: z.string(),
|
|
97
|
+
items: z.array(keyValueItemSchema),
|
|
98
|
+
}).strict();
|
|
99
|
+
const sectionSchema = z.discriminatedUnion("type", [
|
|
100
|
+
timelineSectionSchema,
|
|
101
|
+
listSectionSchema,
|
|
102
|
+
tagsSectionSchema,
|
|
103
|
+
gridSectionSchema,
|
|
104
|
+
tableSectionSchema,
|
|
105
|
+
keyValueSectionSchema,
|
|
106
|
+
]);
|
|
107
|
+
export const infoboxSchema = z.object({
|
|
108
|
+
header: headerBlockSchema.default({ details: [], links: [] }),
|
|
109
|
+
sections: z.array(sectionSchema).default([]),
|
|
110
|
+
}).strict();
|
|
111
|
+
// ── Frontmatter — mirrors backend PagePublishFrontmatter ────────
|
|
112
|
+
export const pagePublishFrontmatterSchema = z.object({
|
|
113
|
+
title: z.string().min(1).max(500),
|
|
114
|
+
topics: z.array(z.string()).default([]),
|
|
115
|
+
sources: z.array(sourceSchema).default([]),
|
|
116
|
+
infobox: infoboxSchema.nullable().optional(),
|
|
117
|
+
// Informational round-trip field — `_serialize_page` embeds the wiki slug
|
|
118
|
+
// in downloaded frontmatter so agents know what they're editing. Backend
|
|
119
|
+
// ignores it at publish (wiki is taken from the URL param).
|
|
120
|
+
wiki: z.string().nullable().optional(),
|
|
121
|
+
edit_summary: z.string().nullable().optional(),
|
|
122
|
+
change_title: z.string().nullable().optional(),
|
|
123
|
+
change_description: z.string().nullable().optional(),
|
|
124
|
+
}).strict();
|
|
125
|
+
// ── Public surface ──────────────────────────────────────────────
|
|
6
126
|
export function parseFrontmatter(raw) {
|
|
7
127
|
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
8
128
|
if (!match) {
|
|
@@ -11,117 +131,20 @@ export function parseFrontmatter(raw) {
|
|
|
11
131
|
const frontmatter = parseYaml(match[1]);
|
|
12
132
|
return { frontmatter, content: match[2] };
|
|
13
133
|
}
|
|
134
|
+
function zodIssueToError(issue) {
|
|
135
|
+
const path = issue.path.length === 0 ? "(root)" : issue.path.join(".");
|
|
136
|
+
return { field: path, message: issue.message };
|
|
137
|
+
}
|
|
14
138
|
export function validateArticle(raw) {
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
errors.push({ field: "content", message: "Article content is required" });
|
|
20
|
-
}
|
|
21
|
-
// title
|
|
22
|
-
const title = frontmatter.title;
|
|
23
|
-
if (!title || typeof title !== "string" || title.trim().length === 0) {
|
|
24
|
-
errors.push({ field: "title", message: "Title is required" });
|
|
25
|
-
}
|
|
26
|
-
else if (title.length > 500) {
|
|
27
|
-
errors.push({ field: "title", message: "Title must be 500 characters or fewer" });
|
|
139
|
+
const { frontmatter } = parseFrontmatter(raw);
|
|
140
|
+
try {
|
|
141
|
+
pagePublishFrontmatterSchema.parse(frontmatter);
|
|
142
|
+
return [];
|
|
28
143
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
errors.push({
|
|
33
|
-
field: "wiki",
|
|
34
|
-
message: "Must be kebab-case (e.g. 'lockpicking')",
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
const topics = frontmatter.topics;
|
|
38
|
-
if (topics != null) {
|
|
39
|
-
if (!Array.isArray(topics)) {
|
|
40
|
-
errors.push({ field: "topics", message: "Topics must be an array of kebab-case slugs" });
|
|
41
|
-
}
|
|
42
|
-
else {
|
|
43
|
-
for (let i = 0; i < topics.length; i++) {
|
|
44
|
-
const topic = topics[i];
|
|
45
|
-
if (typeof topic !== "string" || !SLUG_RE.test(topic)) {
|
|
46
|
-
errors.push({
|
|
47
|
-
field: `topics[${i}]`,
|
|
48
|
-
message: "Must be kebab-case (e.g. 'spool-pins')",
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
// sources
|
|
55
|
-
const sources = frontmatter.sources;
|
|
56
|
-
if (!Array.isArray(sources)) {
|
|
57
|
-
errors.push({ field: "sources", message: "Sources must be an array" });
|
|
58
|
-
}
|
|
59
|
-
else {
|
|
60
|
-
const seenKeys = new Set();
|
|
61
|
-
for (let i = 0; i < sources.length; i++) {
|
|
62
|
-
const s = sources[i];
|
|
63
|
-
// key validation
|
|
64
|
-
const key = s.key;
|
|
65
|
-
if (!key || typeof key !== "string") {
|
|
66
|
-
errors.push({ field: `sources[${i}].key`, message: "Key is required" });
|
|
67
|
-
}
|
|
68
|
-
else if (!KEY_RE.test(key)) {
|
|
69
|
-
errors.push({
|
|
70
|
-
field: `sources[${i}].key`,
|
|
71
|
-
message: `Key "${key}" must be kebab-case with at least one hyphen (e.g. 'nytimes-climate-report')`,
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
else if (seenKeys.has(key)) {
|
|
75
|
-
errors.push({ field: `sources[${i}].key`, message: `Duplicate key "${key}"` });
|
|
76
|
-
}
|
|
77
|
-
else {
|
|
78
|
-
seenKeys.add(key);
|
|
79
|
-
}
|
|
80
|
-
if (!s.url || typeof s.url !== "string") {
|
|
81
|
-
errors.push({ field: `sources[${i}].url`, message: "URL is required" });
|
|
82
|
-
}
|
|
83
|
-
if (!s.title || typeof s.title !== "string") {
|
|
84
|
-
errors.push({ field: `sources[${i}].title`, message: "Title is required" });
|
|
85
|
-
}
|
|
86
|
-
const accessedDate = s.accessed_date;
|
|
87
|
-
if (!accessedDate) {
|
|
88
|
-
errors.push({ field: `sources[${i}].accessed_date`, message: "Accessed date is required" });
|
|
89
|
-
}
|
|
90
|
-
else if (accessedDate instanceof Date) {
|
|
91
|
-
// YAML parsed it as a Date object — valid
|
|
92
|
-
}
|
|
93
|
-
else if (typeof accessedDate === "string" && !DATE_RE.test(accessedDate)) {
|
|
94
|
-
errors.push({ field: `sources[${i}].accessed_date`, message: "Must be YYYY-MM-DD format" });
|
|
95
|
-
}
|
|
96
|
-
else if (typeof accessedDate !== "string" && !(accessedDate instanceof Date)) {
|
|
97
|
-
errors.push({ field: `sources[${i}].accessed_date`, message: "Must be YYYY-MM-DD format" });
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
// citation markers — collect all [@key] references from content
|
|
101
|
-
const citedKeys = new Set();
|
|
102
|
-
for (const match of content.matchAll(CITE_RE)) {
|
|
103
|
-
citedKeys.add(match[1]);
|
|
104
|
-
}
|
|
105
|
-
// cross-check: every [@key] in body must have a matching source
|
|
106
|
-
for (const key of citedKeys) {
|
|
107
|
-
if (!seenKeys.has(key)) {
|
|
108
|
-
errors.push({
|
|
109
|
-
field: "citations",
|
|
110
|
-
message: `[@${key}] referenced in content but no source has key "${key}"`,
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
// cross-check: every source key must be referenced at least once
|
|
115
|
-
for (let i = 0; i < sources.length; i++) {
|
|
116
|
-
const s = sources[i];
|
|
117
|
-
const key = s.key;
|
|
118
|
-
if (key && typeof key === "string" && !citedKeys.has(key)) {
|
|
119
|
-
errors.push({
|
|
120
|
-
field: `sources[${i}]`,
|
|
121
|
-
message: `Source "${key}" is never referenced with [@${key}] in the content`,
|
|
122
|
-
});
|
|
123
|
-
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
if (err instanceof ZodError) {
|
|
146
|
+
return err.issues.map(zodIssueToError);
|
|
124
147
|
}
|
|
148
|
+
return [{ field: "(root)", message: String(err) }];
|
|
125
149
|
}
|
|
126
|
-
return errors;
|
|
127
150
|
}
|