@topik/core 0.1.0-alpha.1
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/index.d.mts +171 -0
- package/dist/index.mjs +430 -0
- package/package.json +39 -0
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { Guide, Wiki, WikiPage } from "@topik/schema";
|
|
3
|
+
|
|
4
|
+
//#region src/config/collection.d.ts
|
|
5
|
+
declare const collectionConfigSchema: z.ZodObject<{
|
|
6
|
+
id: z.ZodString;
|
|
7
|
+
title: z.ZodString;
|
|
8
|
+
tags: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
9
|
+
}, "strip", z.ZodTypeAny, {
|
|
10
|
+
id: string;
|
|
11
|
+
title: string;
|
|
12
|
+
tags?: string[] | undefined;
|
|
13
|
+
}, {
|
|
14
|
+
id: string;
|
|
15
|
+
title: string;
|
|
16
|
+
tags?: string[] | undefined;
|
|
17
|
+
}>;
|
|
18
|
+
type CollectionConfig = z.infer<typeof collectionConfigSchema>;
|
|
19
|
+
declare function parseCollectionConfig(raw: unknown): CollectionConfig;
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/config/wiki.d.ts
|
|
22
|
+
type WikiNavNode = string | {
|
|
23
|
+
group: string;
|
|
24
|
+
slug?: string;
|
|
25
|
+
icon?: string;
|
|
26
|
+
children: WikiNavNode[];
|
|
27
|
+
} | {
|
|
28
|
+
tab: string;
|
|
29
|
+
slug?: string;
|
|
30
|
+
icon?: string;
|
|
31
|
+
children: WikiNavNode[];
|
|
32
|
+
} | {
|
|
33
|
+
href: string;
|
|
34
|
+
title: string;
|
|
35
|
+
icon?: string;
|
|
36
|
+
};
|
|
37
|
+
declare const wikiConfigSchema: z.ZodObject<{
|
|
38
|
+
id: z.ZodString;
|
|
39
|
+
title: z.ZodString;
|
|
40
|
+
navigation: z.ZodOptional<z.ZodArray<z.ZodType<WikiNavNode, z.ZodTypeDef, unknown>, "many">>;
|
|
41
|
+
theme: z.ZodOptional<z.ZodObject<{
|
|
42
|
+
colors: z.ZodOptional<z.ZodObject<{
|
|
43
|
+
primary: z.ZodString;
|
|
44
|
+
light: z.ZodOptional<z.ZodString>;
|
|
45
|
+
dark: z.ZodOptional<z.ZodString>;
|
|
46
|
+
}, "strip", z.ZodTypeAny, {
|
|
47
|
+
primary: string;
|
|
48
|
+
light?: string | undefined;
|
|
49
|
+
dark?: string | undefined;
|
|
50
|
+
}, {
|
|
51
|
+
primary: string;
|
|
52
|
+
light?: string | undefined;
|
|
53
|
+
dark?: string | undefined;
|
|
54
|
+
}>>;
|
|
55
|
+
appearance: z.ZodOptional<z.ZodObject<{
|
|
56
|
+
default: z.ZodOptional<z.ZodEnum<["light", "dark", "system"]>>;
|
|
57
|
+
}, "strip", z.ZodTypeAny, {
|
|
58
|
+
default?: "dark" | "light" | "system" | undefined;
|
|
59
|
+
}, {
|
|
60
|
+
default?: "dark" | "light" | "system" | undefined;
|
|
61
|
+
}>>;
|
|
62
|
+
}, "strip", z.ZodTypeAny, {
|
|
63
|
+
colors?: {
|
|
64
|
+
primary: string;
|
|
65
|
+
light?: string | undefined;
|
|
66
|
+
dark?: string | undefined;
|
|
67
|
+
} | undefined;
|
|
68
|
+
appearance?: {
|
|
69
|
+
default?: "dark" | "light" | "system" | undefined;
|
|
70
|
+
} | undefined;
|
|
71
|
+
}, {
|
|
72
|
+
colors?: {
|
|
73
|
+
primary: string;
|
|
74
|
+
light?: string | undefined;
|
|
75
|
+
dark?: string | undefined;
|
|
76
|
+
} | undefined;
|
|
77
|
+
appearance?: {
|
|
78
|
+
default?: "dark" | "light" | "system" | undefined;
|
|
79
|
+
} | undefined;
|
|
80
|
+
}>>;
|
|
81
|
+
}, "strip", z.ZodTypeAny, {
|
|
82
|
+
id: string;
|
|
83
|
+
title: string;
|
|
84
|
+
navigation?: WikiNavNode[] | undefined;
|
|
85
|
+
theme?: {
|
|
86
|
+
colors?: {
|
|
87
|
+
primary: string;
|
|
88
|
+
light?: string | undefined;
|
|
89
|
+
dark?: string | undefined;
|
|
90
|
+
} | undefined;
|
|
91
|
+
appearance?: {
|
|
92
|
+
default?: "dark" | "light" | "system" | undefined;
|
|
93
|
+
} | undefined;
|
|
94
|
+
} | undefined;
|
|
95
|
+
}, {
|
|
96
|
+
id: string;
|
|
97
|
+
title: string;
|
|
98
|
+
navigation?: unknown[] | undefined;
|
|
99
|
+
theme?: {
|
|
100
|
+
colors?: {
|
|
101
|
+
primary: string;
|
|
102
|
+
light?: string | undefined;
|
|
103
|
+
dark?: string | undefined;
|
|
104
|
+
} | undefined;
|
|
105
|
+
appearance?: {
|
|
106
|
+
default?: "dark" | "light" | "system" | undefined;
|
|
107
|
+
} | undefined;
|
|
108
|
+
} | undefined;
|
|
109
|
+
}>;
|
|
110
|
+
type WikiConfig = z.infer<typeof wikiConfigSchema>;
|
|
111
|
+
declare function parseWikiConfig(raw: unknown): WikiConfig;
|
|
112
|
+
//#endregion
|
|
113
|
+
//#region src/resource.d.ts
|
|
114
|
+
type Resource = Guide | Wiki | WikiPage;
|
|
115
|
+
type ResourceType = Resource["type"];
|
|
116
|
+
//#endregion
|
|
117
|
+
//#region src/compile/shared.d.ts
|
|
118
|
+
interface CompileResult {
|
|
119
|
+
resources: Resource[];
|
|
120
|
+
}
|
|
121
|
+
//#endregion
|
|
122
|
+
//#region src/compile/wiki.d.ts
|
|
123
|
+
interface CompileWikiOptions {
|
|
124
|
+
dir: string;
|
|
125
|
+
}
|
|
126
|
+
declare function compileWiki(options: CompileWikiOptions): Promise<CompileResult>;
|
|
127
|
+
//#endregion
|
|
128
|
+
//#region src/compile/guide.d.ts
|
|
129
|
+
interface CompileGuidesOptions {
|
|
130
|
+
dir: string;
|
|
131
|
+
}
|
|
132
|
+
declare function compileGuides(options: CompileGuidesOptions): Promise<CompileResult>;
|
|
133
|
+
//#endregion
|
|
134
|
+
//#region src/compile/index.d.ts
|
|
135
|
+
interface CompileOptions {
|
|
136
|
+
dir: string;
|
|
137
|
+
}
|
|
138
|
+
declare function compile(options: CompileOptions): Promise<CompileResult>;
|
|
139
|
+
//#endregion
|
|
140
|
+
//#region src/validate.d.ts
|
|
141
|
+
interface ValidationError {
|
|
142
|
+
resource: string;
|
|
143
|
+
path: string;
|
|
144
|
+
message: string;
|
|
145
|
+
}
|
|
146
|
+
interface ValidationResult {
|
|
147
|
+
valid: boolean;
|
|
148
|
+
errors: ValidationError[];
|
|
149
|
+
}
|
|
150
|
+
/** Validate the resources produced by `compile` and the CLI export. */
|
|
151
|
+
declare function validateResources(resources: readonly unknown[]): ValidationResult;
|
|
152
|
+
//#endregion
|
|
153
|
+
//#region src/watch.d.ts
|
|
154
|
+
interface WatchOptions {
|
|
155
|
+
dir: string;
|
|
156
|
+
signal?: AbortSignal;
|
|
157
|
+
}
|
|
158
|
+
type UpdateListener = (key: string, resource: Resource) => void;
|
|
159
|
+
type ErrorListener = (error: Error) => void;
|
|
160
|
+
interface Watcher {
|
|
161
|
+
/** Current compiled resources, keyed by `Type/name`. */
|
|
162
|
+
resources: Map<string, Resource>;
|
|
163
|
+
on(event: "update", listener: UpdateListener): this;
|
|
164
|
+
on(event: "error", listener: ErrorListener): this;
|
|
165
|
+
off(event: "update", listener: UpdateListener): this;
|
|
166
|
+
off(event: "error", listener: ErrorListener): this;
|
|
167
|
+
close(): Promise<void>;
|
|
168
|
+
}
|
|
169
|
+
declare function watch(options: WatchOptions): Promise<Watcher>;
|
|
170
|
+
//#endregion
|
|
171
|
+
export { type CollectionConfig, type CompileGuidesOptions, type CompileOptions, type CompileResult, type CompileWikiOptions, type Resource, type ResourceType, type ValidationError, type ValidationResult, type WatchOptions, type Watcher, type WikiConfig, compile, compileGuides, compileWiki, parseCollectionConfig, parseWikiConfig, validateResources, watch };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { access, readFile, readdir } from "node:fs/promises";
|
|
4
|
+
import { parse } from "yaml";
|
|
5
|
+
import Ajv2020 from "ajv/dist/2020.js";
|
|
6
|
+
import addFormats from "ajv-formats";
|
|
7
|
+
import { guideSchema, wikiPageSchema, wikiSchema } from "@topik/schema";
|
|
8
|
+
import { EventEmitter } from "node:events";
|
|
9
|
+
import { watch as watch$1 } from "chokidar";
|
|
10
|
+
//#region src/config/collection.ts
|
|
11
|
+
const collectionConfigSchema = z.object({
|
|
12
|
+
id: z.string().min(1).max(63).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
|
13
|
+
title: z.string().min(1).max(256),
|
|
14
|
+
tags: z.array(z.string().min(1).max(63)).optional()
|
|
15
|
+
});
|
|
16
|
+
function parseCollectionConfig(raw) {
|
|
17
|
+
return collectionConfigSchema.parse(raw);
|
|
18
|
+
}
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/config/wiki.ts
|
|
21
|
+
const wikiPagePath = z.string().min(1).max(512).regex(/^(?:[a-z0-9]+(?:-[a-z0-9]+)*)(?:\/(?:[a-z0-9]+(?:-[a-z0-9]+)*))*$/, "Wiki page paths must use lowercase DNS-style segments separated by '/'");
|
|
22
|
+
const navNodeSchema = z.union([
|
|
23
|
+
wikiPagePath,
|
|
24
|
+
z.object({
|
|
25
|
+
group: z.string().min(1).max(256),
|
|
26
|
+
slug: z.string().min(1).max(256).optional(),
|
|
27
|
+
icon: z.string().min(1).max(256).optional(),
|
|
28
|
+
children: z.lazy(() => z.array(navNodeSchema)).default([])
|
|
29
|
+
}),
|
|
30
|
+
z.object({
|
|
31
|
+
tab: z.string().min(1).max(256),
|
|
32
|
+
slug: z.string().min(1).max(256).optional(),
|
|
33
|
+
icon: z.string().min(1).max(256).optional(),
|
|
34
|
+
children: z.lazy(() => z.array(navNodeSchema)).default([])
|
|
35
|
+
}),
|
|
36
|
+
z.object({
|
|
37
|
+
href: z.string().max(2048).refine((v) => v.startsWith("https://") || v.startsWith("http://"), { message: "href must be an http or https URL" }),
|
|
38
|
+
title: z.string().min(1).max(256),
|
|
39
|
+
icon: z.string().min(1).max(256).optional()
|
|
40
|
+
})
|
|
41
|
+
]);
|
|
42
|
+
const hexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/, "Must be a hex color (e.g. #ff73a8)");
|
|
43
|
+
const themeSchema = z.object({
|
|
44
|
+
colors: z.object({
|
|
45
|
+
primary: hexColor,
|
|
46
|
+
light: hexColor.optional(),
|
|
47
|
+
dark: hexColor.optional()
|
|
48
|
+
}).optional(),
|
|
49
|
+
appearance: z.object({ default: z.enum([
|
|
50
|
+
"light",
|
|
51
|
+
"dark",
|
|
52
|
+
"system"
|
|
53
|
+
]).optional() }).optional()
|
|
54
|
+
});
|
|
55
|
+
const wikiConfigSchema = z.object({
|
|
56
|
+
id: z.string().min(1).max(63).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
|
57
|
+
title: z.string().min(1).max(256),
|
|
58
|
+
navigation: z.array(navNodeSchema).optional(),
|
|
59
|
+
theme: themeSchema.optional()
|
|
60
|
+
});
|
|
61
|
+
function parseWikiConfig(raw) {
|
|
62
|
+
return wikiConfigSchema.parse(raw);
|
|
63
|
+
}
|
|
64
|
+
//#endregion
|
|
65
|
+
//#region src/compile/config.ts
|
|
66
|
+
async function readOptionalConfigFile(dir, candidates) {
|
|
67
|
+
for (const name of candidates) {
|
|
68
|
+
const filePath = join(dir, name);
|
|
69
|
+
let raw;
|
|
70
|
+
try {
|
|
71
|
+
raw = await readFile(filePath, "utf-8");
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (error.code === "ENOENT") continue;
|
|
74
|
+
throw new Error(`Failed to read config file ${filePath}`, { cause: error });
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
return name.endsWith(".json") ? JSON.parse(raw) : parse(raw);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
throw new Error(`Failed to parse config file ${filePath}`, { cause: error });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function findConfigFile(dir, candidates) {
|
|
84
|
+
for (const name of candidates) try {
|
|
85
|
+
await access(join(dir, name));
|
|
86
|
+
return name;
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (error.code === "ENOENT") continue;
|
|
89
|
+
throw new Error(`Failed to access config file ${join(dir, name)}`, { cause: error });
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
//#endregion
|
|
94
|
+
//#region src/compile/shared.ts
|
|
95
|
+
const DNS_LABEL_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
96
|
+
function parseMarkdownFrontmatter(raw, filePath) {
|
|
97
|
+
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
98
|
+
if (!match) return {
|
|
99
|
+
frontmatter: {},
|
|
100
|
+
content: raw
|
|
101
|
+
};
|
|
102
|
+
try {
|
|
103
|
+
const frontmatter = parse(match[1]);
|
|
104
|
+
if (frontmatter == null) return {
|
|
105
|
+
frontmatter: {},
|
|
106
|
+
content: match[2]
|
|
107
|
+
};
|
|
108
|
+
if (typeof frontmatter !== "object" || Array.isArray(frontmatter)) throw new Error("Frontmatter must parse to an object");
|
|
109
|
+
if ("title" in frontmatter && frontmatter.title != null && typeof frontmatter.title !== "string") throw new Error("Frontmatter title must be a string");
|
|
110
|
+
return {
|
|
111
|
+
frontmatter,
|
|
112
|
+
content: match[2]
|
|
113
|
+
};
|
|
114
|
+
} catch (error) {
|
|
115
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
116
|
+
throw new Error(`Invalid frontmatter in ${filePath}: ${message}`, { cause: error });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function extractMarkdownTitle(content, fallback) {
|
|
120
|
+
const match = content.match(/^#\s+(.+)$/m);
|
|
121
|
+
if (match) return match[1].trim();
|
|
122
|
+
return fallback.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
123
|
+
}
|
|
124
|
+
function parseReferenceList(value, fieldName, filePath) {
|
|
125
|
+
if (value == null) return;
|
|
126
|
+
if (!Array.isArray(value)) throw new Error(`${fieldName} in ${filePath} must be an array of resource names`);
|
|
127
|
+
const references = value.map((entry, index) => {
|
|
128
|
+
if (typeof entry !== "string") throw new Error(`${fieldName}[${index}] in ${filePath} must be a string`);
|
|
129
|
+
if (entry.length > 63 || !DNS_LABEL_PATTERN.test(entry)) throw new Error(`${fieldName}[${index}] in ${filePath} must be a DNS-1123 resource name`);
|
|
130
|
+
return entry;
|
|
131
|
+
});
|
|
132
|
+
return references.length > 0 ? references : void 0;
|
|
133
|
+
}
|
|
134
|
+
//#endregion
|
|
135
|
+
//#region src/compile/wiki.ts
|
|
136
|
+
async function compileWiki(options) {
|
|
137
|
+
const dir = resolve(options.dir);
|
|
138
|
+
const raw = await readOptionalConfigFile(dir, [
|
|
139
|
+
"wiki.yaml",
|
|
140
|
+
"wiki.yml",
|
|
141
|
+
"wiki.json"
|
|
142
|
+
]);
|
|
143
|
+
if (raw == null) return { resources: [] };
|
|
144
|
+
const config = parseWikiConfig(raw);
|
|
145
|
+
const pagePaths = config.navigation ? [...new Set(collectPagePaths(config.navigation))] : [];
|
|
146
|
+
const fileContents = await Promise.all(pagePaths.map(async (pagePath) => {
|
|
147
|
+
return readFile(await resolveFilePath(dir, pagePath), "utf-8");
|
|
148
|
+
}));
|
|
149
|
+
const resources = [];
|
|
150
|
+
for (let i = 0; i < pagePaths.length; i++) {
|
|
151
|
+
const pagePath = pagePaths[i];
|
|
152
|
+
const { frontmatter, content } = parseMarkdownFrontmatter(fileContents[i], pagePath);
|
|
153
|
+
const name = pagePathToName(pagePath);
|
|
154
|
+
const title = typeof frontmatter.title === "string" ? frontmatter.title : extractMarkdownTitle(content, name);
|
|
155
|
+
const pageResource = {
|
|
156
|
+
apiVersion: "v1",
|
|
157
|
+
type: "WikiPage",
|
|
158
|
+
name: `${config.id}-${name}`,
|
|
159
|
+
spec: {
|
|
160
|
+
wiki: config.id,
|
|
161
|
+
title,
|
|
162
|
+
content: {
|
|
163
|
+
format: "topik",
|
|
164
|
+
value: content
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
resources.push(pageResource);
|
|
169
|
+
}
|
|
170
|
+
const wikiResource = {
|
|
171
|
+
apiVersion: "v1",
|
|
172
|
+
type: "Wiki",
|
|
173
|
+
name: config.id,
|
|
174
|
+
spec: {
|
|
175
|
+
title: config.title,
|
|
176
|
+
...config.navigation ? { navigation: resolveNavigation(config.navigation, config.id) } : {},
|
|
177
|
+
...config.theme ? { theme: config.theme } : {}
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
resources.push(wikiResource);
|
|
181
|
+
return { resources };
|
|
182
|
+
}
|
|
183
|
+
function collectPagePaths(nodes) {
|
|
184
|
+
const paths = [];
|
|
185
|
+
for (const node of nodes) if (typeof node === "string") paths.push(node);
|
|
186
|
+
else if ("group" in node || "tab" in node) paths.push(...collectPagePaths(node.children));
|
|
187
|
+
return paths;
|
|
188
|
+
}
|
|
189
|
+
async function resolveFilePath(dir, pagePath) {
|
|
190
|
+
for (const ext of [".mdx", ".md"]) {
|
|
191
|
+
const filePath = join(dir, pagePath + ext);
|
|
192
|
+
try {
|
|
193
|
+
await access(filePath);
|
|
194
|
+
return filePath;
|
|
195
|
+
} catch {}
|
|
196
|
+
}
|
|
197
|
+
throw new Error(`Page not found: ${pagePath} (tried .md and .mdx in ${dir})`);
|
|
198
|
+
}
|
|
199
|
+
function pagePathToSlug(pagePath) {
|
|
200
|
+
if (pagePath === "index") return "";
|
|
201
|
+
return pagePath.replace(/\/index$/, "");
|
|
202
|
+
}
|
|
203
|
+
function pagePathToName(pagePath) {
|
|
204
|
+
return pagePath.replace(/^\//, "").replaceAll("/", "-");
|
|
205
|
+
}
|
|
206
|
+
function resolveNavigation(nodes, wikiId) {
|
|
207
|
+
return nodes.map((node) => {
|
|
208
|
+
if (typeof node === "string") return {
|
|
209
|
+
type: "page",
|
|
210
|
+
page: `${wikiId}-${pagePathToName(node)}`,
|
|
211
|
+
slug: pagePathToSlug(node)
|
|
212
|
+
};
|
|
213
|
+
if ("group" in node || "tab" in node) {
|
|
214
|
+
const isTab = "tab" in node;
|
|
215
|
+
const title = isTab ? node.tab : node.group;
|
|
216
|
+
return {
|
|
217
|
+
type: isTab ? "tab" : "group",
|
|
218
|
+
title,
|
|
219
|
+
...node.slug ? { slug: node.slug } : {},
|
|
220
|
+
...node.icon ? { icon: node.icon } : {},
|
|
221
|
+
children: resolveNavigation(node.children, wikiId)
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
type: "link",
|
|
226
|
+
title: node.title,
|
|
227
|
+
href: node.href,
|
|
228
|
+
...node.icon ? { icon: node.icon } : {}
|
|
229
|
+
};
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
//#endregion
|
|
233
|
+
//#region src/compile/guide.ts
|
|
234
|
+
async function compileGuides(options) {
|
|
235
|
+
const dir = resolve(options.dir);
|
|
236
|
+
const raw = await readOptionalConfigFile(dir, [
|
|
237
|
+
"collection.yaml",
|
|
238
|
+
"collection.yml",
|
|
239
|
+
"collection.json"
|
|
240
|
+
]);
|
|
241
|
+
if (raw == null) return { resources: [] };
|
|
242
|
+
const config = parseCollectionConfig(raw);
|
|
243
|
+
const markdownFiles = (await readdir(dir)).filter((f) => f.endsWith(".md") || f.endsWith(".mdx")).sort();
|
|
244
|
+
const resources = [];
|
|
245
|
+
for (const file of markdownFiles) {
|
|
246
|
+
const { frontmatter, content } = parseMarkdownFrontmatter(await readFile(join(dir, file), "utf-8"), file);
|
|
247
|
+
const slug = fileToSlug(file);
|
|
248
|
+
const name = `${config.id}-${slug}`;
|
|
249
|
+
const title = typeof frontmatter.title === "string" ? frontmatter.title : extractMarkdownTitle(content, slug);
|
|
250
|
+
const tags = mergeTags(config.tags, frontmatter.tags);
|
|
251
|
+
const authors = parseReferenceList(frontmatter.authors, "authors", file);
|
|
252
|
+
const description = typeof frontmatter.description === "string" ? frontmatter.description : void 0;
|
|
253
|
+
const guide = {
|
|
254
|
+
apiVersion: "v1",
|
|
255
|
+
type: "Guide",
|
|
256
|
+
name,
|
|
257
|
+
spec: {
|
|
258
|
+
title,
|
|
259
|
+
slug,
|
|
260
|
+
...description != null ? { description } : {},
|
|
261
|
+
...authors != null ? { authors } : {},
|
|
262
|
+
...tags.length > 0 ? { tags } : {},
|
|
263
|
+
content: {
|
|
264
|
+
format: "topik",
|
|
265
|
+
value: content
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
resources.push(guide);
|
|
270
|
+
}
|
|
271
|
+
return { resources };
|
|
272
|
+
}
|
|
273
|
+
function fileToSlug(filename) {
|
|
274
|
+
return filename.replace(/\.(mdx?|md)$/, "");
|
|
275
|
+
}
|
|
276
|
+
function mergeTags(collectionTags, frontmatterTags) {
|
|
277
|
+
const tags = /* @__PURE__ */ new Set();
|
|
278
|
+
if (collectionTags) for (const tag of collectionTags) tags.add(tag);
|
|
279
|
+
if (Array.isArray(frontmatterTags)) {
|
|
280
|
+
for (const tag of frontmatterTags) if (typeof tag === "string") tags.add(tag);
|
|
281
|
+
}
|
|
282
|
+
return [...tags];
|
|
283
|
+
}
|
|
284
|
+
//#endregion
|
|
285
|
+
//#region src/compile/index.ts
|
|
286
|
+
const WIKI_CONFIG_FILES = [
|
|
287
|
+
"wiki.yaml",
|
|
288
|
+
"wiki.yml",
|
|
289
|
+
"wiki.json"
|
|
290
|
+
];
|
|
291
|
+
const COLLECTION_CONFIG_FILES = [
|
|
292
|
+
"collection.yaml",
|
|
293
|
+
"collection.yml",
|
|
294
|
+
"collection.json"
|
|
295
|
+
];
|
|
296
|
+
async function compile(options) {
|
|
297
|
+
const dir = resolve(options.dir);
|
|
298
|
+
const resources = [];
|
|
299
|
+
if (await findConfigFile(dir, WIKI_CONFIG_FILES)) {
|
|
300
|
+
const result = await compileWiki({ dir });
|
|
301
|
+
resources.push(...result.resources);
|
|
302
|
+
}
|
|
303
|
+
if (await findConfigFile(dir, COLLECTION_CONFIG_FILES)) {
|
|
304
|
+
const result = await compileGuides({ dir });
|
|
305
|
+
resources.push(...result.resources);
|
|
306
|
+
}
|
|
307
|
+
return { resources };
|
|
308
|
+
}
|
|
309
|
+
//#endregion
|
|
310
|
+
//#region src/validate.ts
|
|
311
|
+
const ajv = new Ajv2020({
|
|
312
|
+
strict: true,
|
|
313
|
+
discriminator: true
|
|
314
|
+
});
|
|
315
|
+
addFormats(ajv);
|
|
316
|
+
const validators = {
|
|
317
|
+
Guide: ajv.compile(guideSchema),
|
|
318
|
+
Wiki: ajv.compile(wikiSchema),
|
|
319
|
+
WikiPage: ajv.compile(wikiPageSchema)
|
|
320
|
+
};
|
|
321
|
+
function isRecord(value) {
|
|
322
|
+
return typeof value === "object" && value !== null;
|
|
323
|
+
}
|
|
324
|
+
function getResourceLabel(resource) {
|
|
325
|
+
return `${typeof resource.type === "string" ? resource.type : "unknown"}/${typeof resource.name === "string" ? resource.name : "unknown"}`;
|
|
326
|
+
}
|
|
327
|
+
/** Validate the resources produced by `compile` and the CLI export. */
|
|
328
|
+
function validateResources(resources) {
|
|
329
|
+
const errors = [];
|
|
330
|
+
for (const resource of resources) {
|
|
331
|
+
if (!isRecord(resource)) {
|
|
332
|
+
errors.push({
|
|
333
|
+
resource: "unknown/unknown",
|
|
334
|
+
path: "/",
|
|
335
|
+
message: "Resource must be an object"
|
|
336
|
+
});
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (typeof resource.type !== "string") {
|
|
340
|
+
errors.push({
|
|
341
|
+
resource: getResourceLabel(resource),
|
|
342
|
+
path: "/type",
|
|
343
|
+
message: "Resource type must be a string"
|
|
344
|
+
});
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
const validate = validators[resource.type];
|
|
348
|
+
if (!validate) {
|
|
349
|
+
errors.push({
|
|
350
|
+
resource: getResourceLabel(resource),
|
|
351
|
+
path: "/type",
|
|
352
|
+
message: `Unsupported resource type: ${resource.type}`
|
|
353
|
+
});
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (!validate(resource)) for (const err of validate.errors ?? []) errors.push({
|
|
357
|
+
resource: getResourceLabel(resource),
|
|
358
|
+
path: err.instancePath || "/",
|
|
359
|
+
message: err.message ?? "Unknown error"
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
valid: errors.length === 0,
|
|
364
|
+
errors
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
//#endregion
|
|
368
|
+
//#region src/watch.ts
|
|
369
|
+
function resourceKey(resource) {
|
|
370
|
+
return `${resource.type}/${resource.name}`;
|
|
371
|
+
}
|
|
372
|
+
async function watch(options) {
|
|
373
|
+
const dir = resolve(options.dir);
|
|
374
|
+
const emitter = new EventEmitter();
|
|
375
|
+
const resources = /* @__PURE__ */ new Map();
|
|
376
|
+
const initial = await compile({ dir });
|
|
377
|
+
for (const resource of initial.resources) resources.set(resourceKey(resource), resource);
|
|
378
|
+
let debounceTimer = null;
|
|
379
|
+
async function recompile() {
|
|
380
|
+
try {
|
|
381
|
+
const result = await compile({ dir });
|
|
382
|
+
const newKeys = /* @__PURE__ */ new Set();
|
|
383
|
+
for (const resource of result.resources) {
|
|
384
|
+
const key = resourceKey(resource);
|
|
385
|
+
newKeys.add(key);
|
|
386
|
+
const existing = resources.get(key);
|
|
387
|
+
const serialized = JSON.stringify(resource);
|
|
388
|
+
if (!existing || JSON.stringify(existing) !== serialized) {
|
|
389
|
+
resources.set(key, resource);
|
|
390
|
+
emitter.emit("update", key, resource);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
for (const key of resources.keys()) if (!newKeys.has(key)) resources.delete(key);
|
|
394
|
+
} catch (error) {
|
|
395
|
+
emitter.emit("error", error instanceof Error ? error : new Error(String(error)));
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
function scheduleRecompile() {
|
|
399
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
400
|
+
debounceTimer = setTimeout(recompile, 100);
|
|
401
|
+
}
|
|
402
|
+
const fsWatcher = watch$1(dir, {
|
|
403
|
+
ignoreInitial: true,
|
|
404
|
+
ignored: [/(^|[/\\])\../, "**/node_modules/**"]
|
|
405
|
+
});
|
|
406
|
+
fsWatcher.on("add", scheduleRecompile);
|
|
407
|
+
fsWatcher.on("change", scheduleRecompile);
|
|
408
|
+
fsWatcher.on("unlink", scheduleRecompile);
|
|
409
|
+
if (options.signal) options.signal.addEventListener("abort", () => {
|
|
410
|
+
fsWatcher.close();
|
|
411
|
+
});
|
|
412
|
+
const watcher = {
|
|
413
|
+
resources,
|
|
414
|
+
on(event, listener) {
|
|
415
|
+
emitter.on(event, listener);
|
|
416
|
+
return watcher;
|
|
417
|
+
},
|
|
418
|
+
off(event, listener) {
|
|
419
|
+
emitter.off(event, listener);
|
|
420
|
+
return watcher;
|
|
421
|
+
},
|
|
422
|
+
close: async () => {
|
|
423
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
424
|
+
await fsWatcher.close();
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
return watcher;
|
|
428
|
+
}
|
|
429
|
+
//#endregion
|
|
430
|
+
export { compile, compileGuides, compileWiki, parseCollectionConfig, parseWikiConfig, validateResources, watch };
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@topik/core",
|
|
3
|
+
"version": "0.1.0-alpha.1",
|
|
4
|
+
"description": "Topik core library",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist"
|
|
8
|
+
],
|
|
9
|
+
"type": "module",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./dist/index.mjs",
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "vp pack",
|
|
19
|
+
"dev": "vp pack --watch",
|
|
20
|
+
"test": "vp test",
|
|
21
|
+
"check": "vp check",
|
|
22
|
+
"prepublishOnly": "vp run build"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@topik/schema": "workspace:*",
|
|
26
|
+
"ajv": "^8.18.0",
|
|
27
|
+
"ajv-formats": "^3.0.1",
|
|
28
|
+
"chokidar": "^4.0.0",
|
|
29
|
+
"yaml": "^2.7.0",
|
|
30
|
+
"zod": "^3.25.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "catalog:",
|
|
34
|
+
"@typescript/native-preview": "catalog:",
|
|
35
|
+
"typescript": "catalog:",
|
|
36
|
+
"vite-plus": "catalog:",
|
|
37
|
+
"vitest": "catalog:"
|
|
38
|
+
}
|
|
39
|
+
}
|