openalmanac 0.2.56 → 0.2.58

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/validate.js CHANGED
@@ -1,8 +1,132 @@
1
1
  import { parse as parseYaml } from "yaml";
2
- const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
3
- const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
4
- const KEY_RE = /^[a-z0-9]+-[a-z0-9]+(-[a-z0-9]+)*$/;
5
- const CITE_RE = /\[@([a-z0-9]+-[a-z0-9]+(?:-[a-z0-9]+)*)\]/g;
2
+ import { z, ZodError } from "zod";
3
+ // ── Primitives ──────────────────────────────────────────────────
4
+ const sourceKeyRe = /^[a-z0-9]+-[a-z0-9]+(-[a-z0-9]+)*$/;
5
+ // Match pydantic's `date` coercion: a bare YYYY-MM-DD date, OR any ISO-8601
6
+ // datetime prefix — pydantic accepts "2026-04-17T00:00:00Z" as a date. yaml
7
+ // may deserialize either as a `Date` object or a string.
8
+ const isoDateLikeRe = /^\d{4}-\d{2}-\d{2}([T ].*)?$/;
9
+ const accessedDateSchema = z.union([
10
+ z.date(),
11
+ z.string().regex(isoDateLikeRe, "Must be YYYY-MM-DD (optionally with an ISO time suffix)"),
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
+ // Matches `default_factory=HeaderBlock` on the pydantic model. A factory
40
+ // (not a literal) so each default produces a fresh object — preventing
41
+ // shared-reference aliasing if a caller mutates the default.
42
+ const emptyHeader = () => ({ details: [], links: [] });
43
+ const timelineItemSchema = z.object({
44
+ primary: z.string(),
45
+ secondary: z.string().nullable().optional(),
46
+ period: z.string().nullable().optional(),
47
+ location: z.string().nullable().optional(),
48
+ description: z.array(z.string()).nullable().optional(),
49
+ link: z.string().nullable().optional(),
50
+ }).strict();
51
+ const listItemSchema = z.object({
52
+ title: z.string(),
53
+ subtitle: z.string().nullable().optional(),
54
+ year: z.string().nullable().optional(),
55
+ description: z.array(z.string()).nullable().optional(),
56
+ link: z.string().nullable().optional(),
57
+ }).strict();
58
+ const gridItemSchema = z.object({
59
+ title: z.string(),
60
+ image_url: z.string().nullable().optional(),
61
+ year: z.string().nullable().optional(),
62
+ description: z.string().nullable().optional(),
63
+ link: z.string().nullable().optional(),
64
+ type: z.string().nullable().optional(),
65
+ }).strict();
66
+ const tableRowSchema = z.object({
67
+ cells: z.array(z.string()),
68
+ }).strict();
69
+ const tableDataSchema = z.object({
70
+ headers: z.array(z.string()).min(1, "Table headers must be non-empty"),
71
+ rows: z.array(tableRowSchema),
72
+ }).strict();
73
+ const timelineSectionSchema = z.object({
74
+ type: z.literal("timeline"),
75
+ title: z.string(),
76
+ items: z.array(timelineItemSchema),
77
+ }).strict();
78
+ const listSectionSchema = z.object({
79
+ type: z.literal("list"),
80
+ title: z.string(),
81
+ items: z.array(listItemSchema),
82
+ }).strict();
83
+ const tagsSectionSchema = z.object({
84
+ type: z.literal("tags"),
85
+ title: z.string(),
86
+ items: z.array(z.string()),
87
+ }).strict();
88
+ const gridSectionSchema = z.object({
89
+ type: z.literal("grid"),
90
+ title: z.string(),
91
+ items: z.array(gridItemSchema),
92
+ }).strict();
93
+ const tableSectionSchema = z.object({
94
+ type: z.literal("table"),
95
+ title: z.string(),
96
+ items: tableDataSchema,
97
+ }).strict();
98
+ const keyValueSectionSchema = z.object({
99
+ type: z.literal("key_value"),
100
+ title: z.string(),
101
+ items: z.array(keyValueItemSchema),
102
+ }).strict();
103
+ const sectionSchema = z.discriminatedUnion("type", [
104
+ timelineSectionSchema,
105
+ listSectionSchema,
106
+ tagsSectionSchema,
107
+ gridSectionSchema,
108
+ tableSectionSchema,
109
+ keyValueSectionSchema,
110
+ ]);
111
+ export const infoboxSchema = z.object({
112
+ header: headerBlockSchema.default(emptyHeader),
113
+ sections: z.array(sectionSchema).default([]),
114
+ }).strict();
115
+ // ── Frontmatter — mirrors backend PagePublishFrontmatter ────────
116
+ export const pagePublishFrontmatterSchema = z.object({
117
+ title: z.string().min(1).max(500),
118
+ topics: z.array(z.string()).default([]),
119
+ sources: z.array(sourceSchema).default([]),
120
+ infobox: infoboxSchema.nullable().optional(),
121
+ // Informational round-trip field — `_serialize_page` embeds the wiki slug
122
+ // in downloaded frontmatter so agents know what they're editing. Backend
123
+ // ignores it at publish (wiki is taken from the URL param).
124
+ wiki: z.string().nullable().optional(),
125
+ edit_summary: z.string().nullable().optional(),
126
+ change_title: z.string().nullable().optional(),
127
+ change_description: z.string().nullable().optional(),
128
+ }).strict();
129
+ // ── Public surface ──────────────────────────────────────────────
6
130
  export function parseFrontmatter(raw) {
7
131
  const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
8
132
  if (!match) {
@@ -11,117 +135,20 @@ export function parseFrontmatter(raw) {
11
135
  const frontmatter = parseYaml(match[1]);
12
136
  return { frontmatter, content: match[2] };
13
137
  }
138
+ function zodIssueToError(issue) {
139
+ const path = issue.path.length === 0 ? "(root)" : issue.path.join(".");
140
+ return { field: path, message: issue.message };
141
+ }
14
142
  export function validateArticle(raw) {
15
- const errors = [];
16
- const { frontmatter, content } = parseFrontmatter(raw);
17
- // content
18
- if (!content || content.trim().length === 0) {
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" });
143
+ const { frontmatter } = parseFrontmatter(raw);
144
+ try {
145
+ pagePublishFrontmatterSchema.parse(frontmatter);
146
+ return [];
28
147
  }
29
- // wiki (informational, not identity)
30
- const wiki = frontmatter.wiki;
31
- if (wiki != null && (typeof wiki !== "string" || !SLUG_RE.test(wiki))) {
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
- }
148
+ catch (err) {
149
+ if (err instanceof ZodError) {
150
+ return err.issues.map(zodIssueToError);
124
151
  }
152
+ return [{ field: "(root)", message: String(err) }];
125
153
  }
126
- return errors;
127
154
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openalmanac",
3
- "version": "0.2.56",
3
+ "version": "0.2.58",
4
4
  "description": "OpenAlmanac — pull, edit, and push articles to the open knowledge base",
5
5
  "type": "module",
6
6
  "bin": {