@topik/core 0.1.0-alpha.3 → 0.1.0-alpha.4
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 +50 -6
- package/dist/index.mjs +388 -52
- package/package.json +9 -10
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { Asset, Guide, Wiki, WikiPage } from "@topik/schema";
|
|
3
|
+
import { TopikContentDiagnostic } from "@topik/content-schema";
|
|
3
4
|
|
|
4
5
|
//#region src/config/collection.d.ts
|
|
5
6
|
declare const collectionConfigSchema: z.ZodObject<{
|
|
@@ -11,24 +12,48 @@ type CollectionConfig = z.infer<typeof collectionConfigSchema>;
|
|
|
11
12
|
declare function parseCollectionConfig(raw: unknown): CollectionConfig;
|
|
12
13
|
//#endregion
|
|
13
14
|
//#region src/config/wiki.d.ts
|
|
14
|
-
type
|
|
15
|
-
|
|
15
|
+
type WikiPageNavNode = {
|
|
16
|
+
type: "page";
|
|
17
|
+
slug: string;
|
|
18
|
+
icon?: string;
|
|
19
|
+
hidden?: boolean;
|
|
20
|
+
};
|
|
21
|
+
type WikiGroupNavNode = {
|
|
22
|
+
type: "group";
|
|
23
|
+
title: string;
|
|
16
24
|
slug?: string;
|
|
17
25
|
icon?: string;
|
|
26
|
+
expanded?: boolean;
|
|
27
|
+
hidden?: boolean;
|
|
18
28
|
children: WikiNavNode[];
|
|
19
|
-
}
|
|
20
|
-
|
|
29
|
+
};
|
|
30
|
+
type WikiInternalSwitcherNavNode = {
|
|
31
|
+
type: "tab" | "dropdown";
|
|
32
|
+
title: string;
|
|
21
33
|
slug?: string;
|
|
22
34
|
icon?: string;
|
|
35
|
+
hidden?: boolean;
|
|
23
36
|
children: WikiNavNode[];
|
|
24
|
-
}
|
|
37
|
+
};
|
|
38
|
+
type WikiExternalSwitcherNavNode = {
|
|
39
|
+
type: "tab" | "dropdown";
|
|
40
|
+
title: string;
|
|
25
41
|
href: string;
|
|
42
|
+
icon?: string;
|
|
43
|
+
hidden?: boolean;
|
|
44
|
+
};
|
|
45
|
+
type WikiLinkNavNode = {
|
|
46
|
+
type: "link";
|
|
26
47
|
title: string;
|
|
48
|
+
href: string;
|
|
27
49
|
icon?: string;
|
|
50
|
+
hidden?: boolean;
|
|
28
51
|
};
|
|
52
|
+
type WikiNavNode = string | WikiPageNavNode | WikiGroupNavNode | WikiInternalSwitcherNavNode | WikiExternalSwitcherNavNode | WikiLinkNavNode;
|
|
29
53
|
declare const wikiConfigSchema: z.ZodObject<{
|
|
30
54
|
id: z.ZodString;
|
|
31
55
|
title: z.ZodString;
|
|
56
|
+
description: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNull]>>;
|
|
32
57
|
navigation: z.ZodOptional<z.ZodArray<z.ZodType<WikiNavNode, unknown, z.core.$ZodTypeInternals<WikiNavNode, unknown>>>>;
|
|
33
58
|
theme: z.ZodOptional<z.ZodObject<{
|
|
34
59
|
colors: z.ZodOptional<z.ZodObject<{
|
|
@@ -54,26 +79,45 @@ type ResourceType = Resource["type"];
|
|
|
54
79
|
//#endregion
|
|
55
80
|
//#region src/compile/shared.d.ts
|
|
56
81
|
interface CompileResult {
|
|
82
|
+
diagnostics: TopikContentDiagnostic[];
|
|
57
83
|
resources: Resource[];
|
|
58
84
|
}
|
|
85
|
+
type LinkValidationPolicy = "error" | "warning" | "off";
|
|
86
|
+
interface CompileValidationOptions {
|
|
87
|
+
/** How unresolved wiki page links and same-page guide fragments are handled. */
|
|
88
|
+
links?: LinkValidationPolicy;
|
|
89
|
+
}
|
|
90
|
+
declare class CompileError extends Error {
|
|
91
|
+
readonly diagnostics: TopikContentDiagnostic[];
|
|
92
|
+
constructor(diagnostics: TopikContentDiagnostic[]);
|
|
93
|
+
}
|
|
94
|
+
declare function isErrorDiagnostic(diagnostic: TopikContentDiagnostic): boolean;
|
|
59
95
|
//#endregion
|
|
60
96
|
//#region src/compile/wiki.d.ts
|
|
61
97
|
interface CompileWikiOptions {
|
|
62
98
|
dir: string;
|
|
99
|
+
validation?: CompileValidationOptions;
|
|
63
100
|
}
|
|
64
101
|
declare function compileWiki(options: CompileWikiOptions): Promise<CompileResult>;
|
|
102
|
+
declare function pagePathToName(wikiId: string, pagePath: string): string;
|
|
65
103
|
//#endregion
|
|
66
104
|
//#region src/compile/guide.d.ts
|
|
67
105
|
interface CompileGuidesOptions {
|
|
68
106
|
dir: string;
|
|
107
|
+
validation?: CompileValidationOptions;
|
|
69
108
|
}
|
|
70
109
|
declare function compileGuides(options: CompileGuidesOptions): Promise<CompileResult>;
|
|
71
110
|
//#endregion
|
|
72
111
|
//#region src/compile/index.d.ts
|
|
73
112
|
interface CompileOptions {
|
|
74
113
|
dir: string;
|
|
114
|
+
validation?: CompileValidationOptions;
|
|
75
115
|
}
|
|
76
116
|
declare function compile(options: CompileOptions): Promise<CompileResult>;
|
|
117
|
+
interface LintResult {
|
|
118
|
+
diagnostics: CompileResult["diagnostics"];
|
|
119
|
+
}
|
|
120
|
+
declare function lint(options: CompileOptions): Promise<LintResult>;
|
|
77
121
|
//#endregion
|
|
78
122
|
//#region src/validate.d.ts
|
|
79
123
|
interface ValidationError {
|
|
@@ -106,4 +150,4 @@ interface Watcher {
|
|
|
106
150
|
}
|
|
107
151
|
declare function watch(options: WatchOptions): Promise<Watcher>;
|
|
108
152
|
//#endregion
|
|
109
|
-
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 };
|
|
153
|
+
export { type CollectionConfig, CompileError, type CompileGuidesOptions, type CompileOptions, type CompileResult, type CompileValidationOptions, type CompileWikiOptions, type LinkValidationPolicy, type LintResult, type Resource, type ResourceType, type ValidationError, type ValidationResult, type WatchOptions, type Watcher, type WikiConfig, compile, compileGuides, compileWiki, isErrorDiagnostic, lint, pagePathToName, parseCollectionConfig, parseWikiConfig, validateResources, watch };
|
package/dist/index.mjs
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { WIKI_EXTERNAL_HREF_PATTERN, WIKI_NAV_ICON_PATTERN, assetSchema, guideSchema, joinWikiPath, wikiPageSchema, wikiSchema } from "@topik/schema";
|
|
2
3
|
import { dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
|
|
3
4
|
import { access, readFile, readdir } from "node:fs/promises";
|
|
4
5
|
import { parse } from "yaml";
|
|
5
6
|
import { createHash } from "node:crypto";
|
|
6
|
-
import
|
|
7
|
+
import { analyzeTopikContent, formatTopikContent, parseTopikContent, validateTopikContent } from "@topik/content-schema";
|
|
7
8
|
import Ajv2020 from "ajv/dist/2020.js";
|
|
8
9
|
import addFormats from "ajv-formats";
|
|
9
|
-
import { assetSchema, guideSchema, wikiPageSchema, wikiSchema } from "@topik/schema";
|
|
10
10
|
import { EventEmitter } from "node:events";
|
|
11
11
|
import { watch as watch$1 } from "chokidar";
|
|
12
12
|
//#region src/config/collection.ts
|
|
@@ -20,27 +20,125 @@ function parseCollectionConfig(raw) {
|
|
|
20
20
|
}
|
|
21
21
|
//#endregion
|
|
22
22
|
//#region src/config/wiki.ts
|
|
23
|
+
const wikiNavIconPattern = new RegExp(WIKI_NAV_ICON_PATTERN);
|
|
24
|
+
const wikiExternalHrefPattern = new RegExp(WIKI_EXTERNAL_HREF_PATTERN);
|
|
25
|
+
const pathSegment = z.string().min(1).max(256).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Slugs must be lowercase DNS-style segments");
|
|
23
26
|
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 '/'");
|
|
24
|
-
const
|
|
27
|
+
const title = z.string().min(1).max(256);
|
|
28
|
+
const icon = z.string().min(1).max(256).regex(wikiNavIconPattern, "Icons must be lowercase DNS-style names").optional();
|
|
29
|
+
const href = z.string().max(2048).refine((value) => {
|
|
30
|
+
if (!wikiExternalHrefPattern.test(value)) return false;
|
|
31
|
+
try {
|
|
32
|
+
const url = new URL(value);
|
|
33
|
+
return url.protocol === "https:" || url.protocol === "http:";
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}, { message: "href must be a valid http or https URL" });
|
|
38
|
+
const navNodeSchema = z.lazy(() => z.union([
|
|
25
39
|
wikiPagePath,
|
|
26
40
|
z.object({
|
|
27
|
-
|
|
28
|
-
slug:
|
|
29
|
-
icon
|
|
30
|
-
|
|
31
|
-
}),
|
|
41
|
+
type: z.literal("page"),
|
|
42
|
+
slug: wikiPagePath,
|
|
43
|
+
icon,
|
|
44
|
+
hidden: z.boolean().optional()
|
|
45
|
+
}).strict(),
|
|
32
46
|
z.object({
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
47
|
+
type: z.literal("group"),
|
|
48
|
+
title,
|
|
49
|
+
slug: pathSegment.optional(),
|
|
50
|
+
icon,
|
|
51
|
+
expanded: z.boolean().optional(),
|
|
52
|
+
hidden: z.boolean().optional(),
|
|
53
|
+
children: z.array(navNodeSchema).default([])
|
|
54
|
+
}).strict(),
|
|
38
55
|
z.object({
|
|
39
|
-
|
|
40
|
-
title
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
56
|
+
type: z.union([z.literal("tab"), z.literal("dropdown")]),
|
|
57
|
+
title,
|
|
58
|
+
slug: pathSegment.optional(),
|
|
59
|
+
icon,
|
|
60
|
+
hidden: z.boolean().optional(),
|
|
61
|
+
children: z.array(navNodeSchema).default([])
|
|
62
|
+
}).strict(),
|
|
63
|
+
z.object({
|
|
64
|
+
type: z.union([z.literal("tab"), z.literal("dropdown")]),
|
|
65
|
+
title,
|
|
66
|
+
href,
|
|
67
|
+
icon,
|
|
68
|
+
hidden: z.boolean().optional()
|
|
69
|
+
}).strict(),
|
|
70
|
+
z.object({
|
|
71
|
+
type: z.literal("link"),
|
|
72
|
+
title,
|
|
73
|
+
href,
|
|
74
|
+
icon,
|
|
75
|
+
hidden: z.boolean().optional()
|
|
76
|
+
}).strict()
|
|
77
|
+
]));
|
|
78
|
+
function nodeKind(node) {
|
|
79
|
+
if (typeof node !== "string" && node.type === "tab") return "tab";
|
|
80
|
+
if (typeof node !== "string" && node.type === "dropdown") return "dropdown";
|
|
81
|
+
return "sidebar";
|
|
82
|
+
}
|
|
83
|
+
function validateHomogeneousLevel(nodes, allowed, path, context) {
|
|
84
|
+
const kinds = nodes.map(nodeKind);
|
|
85
|
+
nodes.forEach((_node, index) => {
|
|
86
|
+
const kind = kinds[index];
|
|
87
|
+
if (!allowed.has(kind)) {
|
|
88
|
+
const nodePath = [...path, index];
|
|
89
|
+
context.addIssue({
|
|
90
|
+
code: "custom",
|
|
91
|
+
path: nodePath,
|
|
92
|
+
message: `Navigation ${kind} nodes are not allowed at ${formatConfigPath(nodePath)}`
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
const expectedKind = kinds[0];
|
|
97
|
+
kinds.forEach((kind, index) => {
|
|
98
|
+
if (kind === expectedKind) return;
|
|
99
|
+
const nodePath = [...path, index];
|
|
100
|
+
context.addIssue({
|
|
101
|
+
code: "custom",
|
|
102
|
+
path: nodePath,
|
|
103
|
+
message: `Navigation surfaces cannot be mixed at the same level: expected ${expectedKind}, found ${kind} at ${formatConfigPath(nodePath)}`
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
nodes.forEach((node, index) => {
|
|
107
|
+
if (typeof node === "string" || !("children" in node)) return;
|
|
108
|
+
const childPath = [
|
|
109
|
+
...path,
|
|
110
|
+
index,
|
|
111
|
+
"children"
|
|
112
|
+
];
|
|
113
|
+
if (node.type === "tab") validateHomogeneousLevel(node.children, /* @__PURE__ */ new Set(["dropdown", "sidebar"]), childPath, context);
|
|
114
|
+
else validateHomogeneousLevel(node.children, /* @__PURE__ */ new Set(["sidebar"]), childPath, context);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
function validateUniquePageRoutes(nodes, path, context, prefix = "", routes = /* @__PURE__ */ new Map()) {
|
|
118
|
+
nodes.forEach((node, index) => {
|
|
119
|
+
const nodePath = [...path, index];
|
|
120
|
+
if (typeof node === "string" || node.type === "page") {
|
|
121
|
+
const sourcePath = joinNavigationPath(prefix, typeof node === "string" ? node : node.slug);
|
|
122
|
+
const route = sourcePath === "index" ? "" : sourcePath.replace(/\/index$/, "");
|
|
123
|
+
const previousPath = routes.get(route);
|
|
124
|
+
if (previousPath) context.addIssue({
|
|
125
|
+
code: "custom",
|
|
126
|
+
path: nodePath,
|
|
127
|
+
message: `Navigation contains duplicate page route /${route}: first defined at ${formatConfigPath(previousPath)}, duplicated at ${formatConfigPath(nodePath)}`
|
|
128
|
+
});
|
|
129
|
+
else routes.set(route, nodePath);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if ("children" in node) validateUniquePageRoutes(node.children, [...nodePath, "children"], context, joinNavigationPath(prefix, node.slug), routes);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
function formatConfigPath(path) {
|
|
136
|
+
return path.reduce((formatted, segment) => typeof segment === "number" ? `${formatted}[${segment}]` : formatted ? `${formatted}.${segment}` : segment, "");
|
|
137
|
+
}
|
|
138
|
+
function joinNavigationPath(prefix, path) {
|
|
139
|
+
if (!path) return prefix;
|
|
140
|
+
return prefix ? `${prefix}/${path}` : path;
|
|
141
|
+
}
|
|
44
142
|
const hexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/, "Must be a hex color (e.g. #ff73a8)");
|
|
45
143
|
const themeSchema = z.object({
|
|
46
144
|
colors: z.object({
|
|
@@ -54,11 +152,23 @@ const themeSchema = z.object({
|
|
|
54
152
|
"system"
|
|
55
153
|
]).optional() }).optional()
|
|
56
154
|
});
|
|
155
|
+
const nameRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
156
|
+
const MAX_WIKI_ID_FOR_PAGE_NAMES = 46;
|
|
57
157
|
const wikiConfigSchema = z.object({
|
|
58
|
-
id: z.string().min(1).max(
|
|
158
|
+
id: z.string().min(1).max(MAX_WIKI_ID_FOR_PAGE_NAMES, `Wiki id must be ${MAX_WIKI_ID_FOR_PAGE_NAMES} characters or fewer`).regex(nameRegex),
|
|
59
159
|
title: z.string().min(1).max(256),
|
|
160
|
+
description: z.union([z.string().max(1024), z.null()]).optional(),
|
|
60
161
|
navigation: z.array(navNodeSchema).optional(),
|
|
61
162
|
theme: themeSchema.optional()
|
|
163
|
+
}).superRefine((config, context) => {
|
|
164
|
+
if (config.navigation) {
|
|
165
|
+
validateHomogeneousLevel(config.navigation, /* @__PURE__ */ new Set([
|
|
166
|
+
"tab",
|
|
167
|
+
"dropdown",
|
|
168
|
+
"sidebar"
|
|
169
|
+
]), ["navigation"], context);
|
|
170
|
+
validateUniquePageRoutes(config.navigation, ["navigation"], context);
|
|
171
|
+
}
|
|
62
172
|
});
|
|
63
173
|
function parseWikiConfig(raw) {
|
|
64
174
|
return wikiConfigSchema.parse(raw);
|
|
@@ -124,10 +234,14 @@ const MIME_BY_EXT = {
|
|
|
124
234
|
".ppt": "application/vnd.ms-powerpoint",
|
|
125
235
|
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
|
126
236
|
};
|
|
127
|
-
const ASSET_TAG_ATTRS = [
|
|
237
|
+
const ASSET_TAG_ATTRS = [
|
|
238
|
+
"src",
|
|
239
|
+
"darkSrc",
|
|
240
|
+
"href"
|
|
241
|
+
];
|
|
128
242
|
async function extractAssets(source, options) {
|
|
129
243
|
const { baseDir, filePath } = options;
|
|
130
|
-
const ast =
|
|
244
|
+
const ast = parseTopikContent(source);
|
|
131
245
|
const refs = /* @__PURE__ */ new Map();
|
|
132
246
|
const slots = /* @__PURE__ */ new Map();
|
|
133
247
|
const collect = (url, slot, requireKnownExtension) => {
|
|
@@ -203,7 +317,7 @@ async function extractAssets(source, options) {
|
|
|
203
317
|
for (const slot of slotList) slot.node.attributes[slot.attr] = `asset:${id}`;
|
|
204
318
|
}
|
|
205
319
|
return {
|
|
206
|
-
content:
|
|
320
|
+
content: formatTopikContent(ast),
|
|
207
321
|
assets: Array.from(byName.values()),
|
|
208
322
|
manifest
|
|
209
323
|
};
|
|
@@ -269,7 +383,8 @@ async function toAsset(ref) {
|
|
|
269
383
|
const integrity = `sha256-${digest.toString("base64")}`;
|
|
270
384
|
const name = digest.toString("hex").slice(0, 16);
|
|
271
385
|
const extMatch = /\.([a-z0-9]+)(?:[?#].*)?$/i.exec(ref.relFromBase);
|
|
272
|
-
const
|
|
386
|
+
const ext = extMatch ? `.${extMatch[1].toLowerCase()}` : "";
|
|
387
|
+
const mediaType = MIME_BY_EXT[ext];
|
|
273
388
|
return {
|
|
274
389
|
apiVersion: "v1",
|
|
275
390
|
type: "Asset",
|
|
@@ -283,6 +398,27 @@ async function toAsset(ref) {
|
|
|
283
398
|
}
|
|
284
399
|
//#endregion
|
|
285
400
|
//#region src/compile/shared.ts
|
|
401
|
+
var CompileError = class extends Error {
|
|
402
|
+
diagnostics;
|
|
403
|
+
constructor(diagnostics) {
|
|
404
|
+
super(formatContentDiagnostics(diagnostics));
|
|
405
|
+
this.diagnostics = diagnostics;
|
|
406
|
+
this.name = "CompileError";
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
function linkValidationPolicy(options) {
|
|
410
|
+
return options?.links ?? "error";
|
|
411
|
+
}
|
|
412
|
+
function isErrorDiagnostic(diagnostic) {
|
|
413
|
+
return diagnostic.level === "error" || diagnostic.level === "critical";
|
|
414
|
+
}
|
|
415
|
+
function hasCompileErrors(diagnostics) {
|
|
416
|
+
return diagnostics.some(isErrorDiagnostic);
|
|
417
|
+
}
|
|
418
|
+
function throwOnCompileErrors(diagnostics) {
|
|
419
|
+
const errors = diagnostics.filter(isErrorDiagnostic);
|
|
420
|
+
if (errors.length > 0) throw new CompileError(errors);
|
|
421
|
+
}
|
|
286
422
|
const DNS_LABEL_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
287
423
|
function parseMarkdownFrontmatter(raw, filePath) {
|
|
288
424
|
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
@@ -312,6 +448,11 @@ function extractMarkdownTitle(content, fallback) {
|
|
|
312
448
|
if (match) return match[1].trim();
|
|
313
449
|
return fallback.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
314
450
|
}
|
|
451
|
+
function formatContentDiagnostics(diagnostics) {
|
|
452
|
+
return diagnostics.filter(isErrorDiagnostic).map((diagnostic) => {
|
|
453
|
+
return `${diagnostic.file ?? "content"}${diagnostic.lines.length > 0 ? `:${diagnostic.lines.join(",")}` : ""} ${diagnostic.level} ${diagnostic.id}: ${diagnostic.message}`;
|
|
454
|
+
}).join("\n");
|
|
455
|
+
}
|
|
315
456
|
function parseReferenceList(value, fieldName, filePath) {
|
|
316
457
|
if (value == null) return;
|
|
317
458
|
if (!Array.isArray(value)) throw new Error(`${fieldName} in ${filePath} must be an array of resource names`);
|
|
@@ -323,15 +464,99 @@ function parseReferenceList(value, fieldName, filePath) {
|
|
|
323
464
|
return references.length > 0 ? references : void 0;
|
|
324
465
|
}
|
|
325
466
|
//#endregion
|
|
467
|
+
//#region src/compile/links.ts
|
|
468
|
+
const NON_PAGE_SCHEME = /^(?:asset|https?|mailto|tel):/i;
|
|
469
|
+
const LINK_BASE = "https://topik.local";
|
|
470
|
+
function validateWikiLinks(pages, policy) {
|
|
471
|
+
if (policy === "off") return [];
|
|
472
|
+
const level = policy === "error" ? "error" : "warning";
|
|
473
|
+
const pagesBySlug = new Map(pages.map((page) => [page.slug, page]));
|
|
474
|
+
const diagnostics = [];
|
|
475
|
+
for (const page of pages) for (const link of page.analysis.links) {
|
|
476
|
+
if (NON_PAGE_SCHEME.test(link.href)) continue;
|
|
477
|
+
const target = resolveInternalTarget(link.href, page.sourcePath);
|
|
478
|
+
if (!target) continue;
|
|
479
|
+
const targetPage = pagesBySlug.get(target.slug);
|
|
480
|
+
if (!targetPage) {
|
|
481
|
+
diagnostics.push(linkDiagnostic("link-page-not-found", level, `Internal link '${link.href}' resolves to missing page '/${target.slug}'.`, link));
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
if (target.fragment && !targetPage.analysis.headings.some((heading) => heading.id === target.fragment)) diagnostics.push(linkDiagnostic("link-fragment-not-found", level, `Internal link '${link.href}' references missing heading '#${target.fragment}' on '/${target.slug}'.`, link));
|
|
485
|
+
}
|
|
486
|
+
return diagnostics;
|
|
487
|
+
}
|
|
488
|
+
function validateLocalFragments(analysis, policy) {
|
|
489
|
+
if (policy === "off") return [];
|
|
490
|
+
const level = policy === "error" ? "error" : "warning";
|
|
491
|
+
const headingIds = new Set(analysis.headings.map((heading) => heading.id));
|
|
492
|
+
const diagnostics = [];
|
|
493
|
+
for (const link of analysis.links) {
|
|
494
|
+
if (!link.href.startsWith("#") || link.href === "#") continue;
|
|
495
|
+
const fragment = decodeFragment(link.href.slice(1));
|
|
496
|
+
if (fragment && !headingIds.has(fragment)) diagnostics.push(linkDiagnostic("link-fragment-not-found", level, `Link '${link.href}' references a missing heading in this document.`, link));
|
|
497
|
+
}
|
|
498
|
+
return diagnostics;
|
|
499
|
+
}
|
|
500
|
+
function resolveInternalTarget(href, sourcePath) {
|
|
501
|
+
let url;
|
|
502
|
+
try {
|
|
503
|
+
url = new URL(href, `${LINK_BASE}/${sourcePath}`);
|
|
504
|
+
} catch {
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (url.origin !== LINK_BASE) return void 0;
|
|
508
|
+
return {
|
|
509
|
+
fragment: decodeFragment(url.hash.slice(1)),
|
|
510
|
+
slug: decodePath(url.pathname)
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
function decodeFragment(value) {
|
|
514
|
+
if (!value) return "";
|
|
515
|
+
try {
|
|
516
|
+
return decodeURIComponent(value);
|
|
517
|
+
} catch {
|
|
518
|
+
return value;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
function decodePath(pathname) {
|
|
522
|
+
const normalized = pathname.replace(/^\/+|\/+$/g, "").split("/").filter(Boolean).map((segment) => {
|
|
523
|
+
try {
|
|
524
|
+
return decodeURIComponent(segment);
|
|
525
|
+
} catch {
|
|
526
|
+
return segment;
|
|
527
|
+
}
|
|
528
|
+
}).join("/").replace(/\.(?:mdx?|markdown)$/i, "");
|
|
529
|
+
if (normalized === "index") return "";
|
|
530
|
+
return normalized.replace(/\/index$/, "");
|
|
531
|
+
}
|
|
532
|
+
function linkDiagnostic(id, level, message, link) {
|
|
533
|
+
return {
|
|
534
|
+
id,
|
|
535
|
+
type: link.kind,
|
|
536
|
+
level,
|
|
537
|
+
message,
|
|
538
|
+
lines: link.lines,
|
|
539
|
+
...link.file ? { file: link.file } : {}
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
//#endregion
|
|
326
543
|
//#region src/compile/wiki.ts
|
|
327
544
|
async function compileWiki(options) {
|
|
545
|
+
const result = await inspectWiki(options);
|
|
546
|
+
throwOnCompileErrors(result.diagnostics);
|
|
547
|
+
return result;
|
|
548
|
+
}
|
|
549
|
+
async function inspectWiki(options) {
|
|
328
550
|
const dir = resolve(options.dir);
|
|
329
551
|
const raw = await readOptionalConfigFile(dir, [
|
|
330
552
|
"wiki.yaml",
|
|
331
553
|
"wiki.yml",
|
|
332
554
|
"wiki.json"
|
|
333
555
|
]);
|
|
334
|
-
if (raw == null) return {
|
|
556
|
+
if (raw == null) return {
|
|
557
|
+
diagnostics: [],
|
|
558
|
+
resources: []
|
|
559
|
+
};
|
|
335
560
|
const config = parseWikiConfig(raw);
|
|
336
561
|
const pagePaths = config.navigation ? [...new Set(collectPagePaths(config.navigation))] : [];
|
|
337
562
|
const resolvedFiles = await Promise.all(pagePaths.map(async (pagePath) => {
|
|
@@ -342,25 +567,39 @@ async function compileWiki(options) {
|
|
|
342
567
|
};
|
|
343
568
|
}));
|
|
344
569
|
const resources = [];
|
|
570
|
+
const diagnostics = [];
|
|
571
|
+
const pageAnalyses = [];
|
|
345
572
|
const assetsById = /* @__PURE__ */ new Map();
|
|
346
573
|
for (let i = 0; i < pagePaths.length; i++) {
|
|
347
574
|
const pagePath = pagePaths[i];
|
|
348
575
|
const { filePath, raw } = resolvedFiles[i];
|
|
349
576
|
const { frontmatter, content } = parseMarkdownFrontmatter(raw, pagePath);
|
|
577
|
+
const validation = validateTopikContent(content, { file: filePath });
|
|
578
|
+
diagnostics.push(...validation.errors);
|
|
579
|
+
if (!validation.valid) continue;
|
|
350
580
|
const { content: rewritten, assets, manifest } = await extractAssets(content, {
|
|
351
581
|
baseDir: dir,
|
|
352
582
|
filePath
|
|
353
583
|
});
|
|
354
584
|
for (const asset of assets) if (!assetsById.has(asset.name)) assetsById.set(asset.name, asset);
|
|
355
|
-
const name = pagePathToName(pagePath);
|
|
356
|
-
const title = typeof frontmatter.title === "string" ? frontmatter.title : extractMarkdownTitle(rewritten,
|
|
585
|
+
const name = pagePathToName(config.id, pagePath);
|
|
586
|
+
const title = typeof frontmatter.title === "string" ? frontmatter.title : extractMarkdownTitle(rewritten, pagePathToTitleFallback(pagePath));
|
|
587
|
+
const description = normalizeWikiPageDescription(frontmatter.description);
|
|
588
|
+
const analysis = analyzeTopikContent(rewritten, { file: filePath });
|
|
589
|
+
diagnostics.push(...analysis.diagnostics);
|
|
590
|
+
pageAnalyses.push({
|
|
591
|
+
analysis,
|
|
592
|
+
slug: pagePathToSlug(pagePath),
|
|
593
|
+
sourcePath: pagePath
|
|
594
|
+
});
|
|
357
595
|
const pageResource = {
|
|
358
596
|
apiVersion: "v1",
|
|
359
597
|
type: "WikiPage",
|
|
360
|
-
name
|
|
598
|
+
name,
|
|
361
599
|
spec: {
|
|
362
600
|
wiki: config.id,
|
|
363
601
|
title,
|
|
602
|
+
...description != null ? { description } : {},
|
|
364
603
|
content: {
|
|
365
604
|
format: "topik",
|
|
366
605
|
value: rewritten
|
|
@@ -377,17 +616,25 @@ async function compileWiki(options) {
|
|
|
377
616
|
name: config.id,
|
|
378
617
|
spec: {
|
|
379
618
|
title: config.title,
|
|
619
|
+
...config.description != null ? { description: config.description } : {},
|
|
380
620
|
...config.navigation ? { navigation: resolveNavigation(config.navigation, config.id) } : {},
|
|
381
621
|
...config.theme ? { theme: config.theme } : {}
|
|
382
622
|
}
|
|
383
623
|
};
|
|
384
624
|
resources.push(wikiResource);
|
|
385
|
-
|
|
625
|
+
if (!hasCompileErrors(diagnostics)) diagnostics.push(...validateWikiLinks(pageAnalyses, linkValidationPolicy(options.validation)));
|
|
626
|
+
return {
|
|
627
|
+
diagnostics,
|
|
628
|
+
resources
|
|
629
|
+
};
|
|
386
630
|
}
|
|
387
|
-
function
|
|
631
|
+
function normalizeWikiPageDescription(description) {
|
|
632
|
+
return typeof description === "string" ? description.slice(0, 1024) : void 0;
|
|
633
|
+
}
|
|
634
|
+
function collectPagePaths(nodes, prefix = "") {
|
|
388
635
|
const paths = [];
|
|
389
|
-
for (const node of nodes) if (typeof node === "string") paths.push(node);
|
|
390
|
-
else if ("
|
|
636
|
+
for (const node of nodes) if (typeof node === "string" || node.type === "page") paths.push(joinWikiPath(prefix, typeof node === "string" ? node : node.slug));
|
|
637
|
+
else if ("children" in node) paths.push(...collectPagePaths(node.children, joinWikiPath(prefix, node.slug)));
|
|
391
638
|
return paths;
|
|
392
639
|
}
|
|
393
640
|
async function resolveFilePath(dir, pagePath) {
|
|
@@ -404,52 +651,110 @@ function pagePathToSlug(pagePath) {
|
|
|
404
651
|
if (pagePath === "index") return "";
|
|
405
652
|
return pagePath.replace(/\/index$/, "");
|
|
406
653
|
}
|
|
407
|
-
function pagePathToName(pagePath) {
|
|
408
|
-
|
|
654
|
+
function pagePathToName(wikiId, pagePath) {
|
|
655
|
+
const normalizedPath = normalizePagePath(pagePath);
|
|
656
|
+
return `${wikiId}-${createHash("sha256").update(normalizedPath).digest("hex").slice(0, 16)}`;
|
|
409
657
|
}
|
|
410
|
-
function resolveNavigation(nodes, wikiId) {
|
|
658
|
+
function resolveNavigation(nodes, wikiId, prefix = "") {
|
|
411
659
|
return nodes.map((node) => {
|
|
412
|
-
if (typeof node === "string")
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
slug: pagePathToSlug(node)
|
|
416
|
-
};
|
|
417
|
-
if ("group" in node || "tab" in node) {
|
|
418
|
-
const isTab = "tab" in node;
|
|
419
|
-
const title = isTab ? node.tab : node.group;
|
|
660
|
+
if (typeof node === "string" || node.type === "page") {
|
|
661
|
+
const localPath = typeof node === "string" ? node : node.slug;
|
|
662
|
+
const pagePath = joinWikiPath(prefix, localPath);
|
|
420
663
|
return {
|
|
421
|
-
type:
|
|
422
|
-
|
|
664
|
+
type: "page",
|
|
665
|
+
page: pagePathToName(wikiId, pagePath),
|
|
666
|
+
slug: pagePathToSlug(localPath),
|
|
667
|
+
sourcePath: pagePath,
|
|
668
|
+
...typeof node !== "string" && node.icon ? { icon: node.icon } : {},
|
|
669
|
+
...typeof node !== "string" && node.hidden ? { hidden: true } : {}
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
if ("children" in node) {
|
|
673
|
+
const children = resolveNavigation(node.children, wikiId, joinWikiPath(prefix, node.slug));
|
|
674
|
+
if (node.type === "group") return {
|
|
675
|
+
type: "group",
|
|
676
|
+
title: node.title,
|
|
677
|
+
...node.slug ? { slug: node.slug } : {},
|
|
678
|
+
...node.icon ? { icon: node.icon } : {},
|
|
679
|
+
...node.hidden ? { hidden: true } : {},
|
|
680
|
+
...node.expanded ? { expanded: true } : {},
|
|
681
|
+
children
|
|
682
|
+
};
|
|
683
|
+
if (node.type === "tab") return {
|
|
684
|
+
type: "tab",
|
|
685
|
+
title: node.title,
|
|
423
686
|
...node.slug ? { slug: node.slug } : {},
|
|
424
687
|
...node.icon ? { icon: node.icon } : {},
|
|
425
|
-
|
|
688
|
+
...node.hidden ? { hidden: true } : {},
|
|
689
|
+
children
|
|
690
|
+
};
|
|
691
|
+
return {
|
|
692
|
+
type: "dropdown",
|
|
693
|
+
title: node.title,
|
|
694
|
+
...node.slug ? { slug: node.slug } : {},
|
|
695
|
+
...node.icon ? { icon: node.icon } : {},
|
|
696
|
+
...node.hidden ? { hidden: true } : {},
|
|
697
|
+
children
|
|
426
698
|
};
|
|
427
699
|
}
|
|
700
|
+
if (node.type === "tab") return {
|
|
701
|
+
type: "tab",
|
|
702
|
+
title: node.title,
|
|
703
|
+
href: node.href,
|
|
704
|
+
...node.icon ? { icon: node.icon } : {},
|
|
705
|
+
...node.hidden ? { hidden: true } : {}
|
|
706
|
+
};
|
|
707
|
+
if (node.type === "dropdown") return {
|
|
708
|
+
type: "dropdown",
|
|
709
|
+
title: node.title,
|
|
710
|
+
href: node.href,
|
|
711
|
+
...node.icon ? { icon: node.icon } : {},
|
|
712
|
+
...node.hidden ? { hidden: true } : {}
|
|
713
|
+
};
|
|
428
714
|
return {
|
|
429
715
|
type: "link",
|
|
430
716
|
title: node.title,
|
|
431
717
|
href: node.href,
|
|
432
|
-
...node.icon ? { icon: node.icon } : {}
|
|
718
|
+
...node.icon ? { icon: node.icon } : {},
|
|
719
|
+
...node.hidden ? { hidden: true } : {}
|
|
433
720
|
};
|
|
434
721
|
});
|
|
435
722
|
}
|
|
723
|
+
function normalizePagePath(pagePath) {
|
|
724
|
+
return pagePath.replace(/^\//, "").replace(/\.(?:mdx?|markdown)$/i, "");
|
|
725
|
+
}
|
|
726
|
+
function pagePathToTitleFallback(pagePath) {
|
|
727
|
+
return normalizePagePath(pagePath).replaceAll("/", "-");
|
|
728
|
+
}
|
|
436
729
|
//#endregion
|
|
437
730
|
//#region src/compile/guide.ts
|
|
438
731
|
async function compileGuides(options) {
|
|
732
|
+
const result = await inspectGuides(options);
|
|
733
|
+
throwOnCompileErrors(result.diagnostics);
|
|
734
|
+
return result;
|
|
735
|
+
}
|
|
736
|
+
async function inspectGuides(options) {
|
|
439
737
|
const dir = resolve(options.dir);
|
|
440
738
|
const raw = await readOptionalConfigFile(dir, [
|
|
441
739
|
"collection.yaml",
|
|
442
740
|
"collection.yml",
|
|
443
741
|
"collection.json"
|
|
444
742
|
]);
|
|
445
|
-
if (raw == null) return {
|
|
743
|
+
if (raw == null) return {
|
|
744
|
+
diagnostics: [],
|
|
745
|
+
resources: []
|
|
746
|
+
};
|
|
446
747
|
const config = parseCollectionConfig(raw);
|
|
447
748
|
const markdownFiles = (await readdir(dir)).filter((f) => f.endsWith(".md") || f.endsWith(".mdx")).sort();
|
|
448
749
|
const resources = [];
|
|
750
|
+
const diagnostics = [];
|
|
449
751
|
const assetsById = /* @__PURE__ */ new Map();
|
|
450
752
|
for (const file of markdownFiles) {
|
|
451
753
|
const filePath = join(dir, file);
|
|
452
754
|
const { frontmatter, content } = parseMarkdownFrontmatter(await readFile(filePath, "utf-8"), file);
|
|
755
|
+
const validation = validateTopikContent(content, { file: filePath });
|
|
756
|
+
diagnostics.push(...validation.errors);
|
|
757
|
+
if (!validation.valid) continue;
|
|
453
758
|
const { content: rewritten, assets, manifest } = await extractAssets(content, {
|
|
454
759
|
baseDir: dir,
|
|
455
760
|
filePath
|
|
@@ -461,6 +766,9 @@ async function compileGuides(options) {
|
|
|
461
766
|
const tags = mergeTags(config.tags, frontmatter.tags);
|
|
462
767
|
const authors = parseReferenceList(frontmatter.authors, "authors", file);
|
|
463
768
|
const description = typeof frontmatter.description === "string" ? frontmatter.description : void 0;
|
|
769
|
+
const analysis = analyzeTopikContent(rewritten, { file: filePath });
|
|
770
|
+
diagnostics.push(...analysis.diagnostics);
|
|
771
|
+
diagnostics.push(...validateLocalFragments(analysis, linkValidationPolicy(options.validation)));
|
|
464
772
|
const guide = {
|
|
465
773
|
apiVersion: "v1",
|
|
466
774
|
type: "Guide",
|
|
@@ -481,7 +789,10 @@ async function compileGuides(options) {
|
|
|
481
789
|
resources.push(guide);
|
|
482
790
|
}
|
|
483
791
|
for (const asset of assetsById.values()) resources.push(asset);
|
|
484
|
-
return {
|
|
792
|
+
return {
|
|
793
|
+
diagnostics,
|
|
794
|
+
resources
|
|
795
|
+
};
|
|
485
796
|
}
|
|
486
797
|
function fileToSlug(filename) {
|
|
487
798
|
return filename.replace(/\.(mdx?|md)$/, "");
|
|
@@ -509,15 +820,40 @@ const COLLECTION_CONFIG_FILES = [
|
|
|
509
820
|
async function compile(options) {
|
|
510
821
|
const dir = resolve(options.dir);
|
|
511
822
|
const resources = [];
|
|
823
|
+
const diagnostics = [];
|
|
512
824
|
if (await findConfigFile(dir, WIKI_CONFIG_FILES)) {
|
|
513
|
-
const result = await compileWiki({
|
|
825
|
+
const result = await compileWiki({
|
|
826
|
+
dir,
|
|
827
|
+
validation: options.validation
|
|
828
|
+
});
|
|
514
829
|
resources.push(...result.resources);
|
|
830
|
+
diagnostics.push(...result.diagnostics);
|
|
515
831
|
}
|
|
516
832
|
if (await findConfigFile(dir, COLLECTION_CONFIG_FILES)) {
|
|
517
|
-
const result = await compileGuides({
|
|
833
|
+
const result = await compileGuides({
|
|
834
|
+
dir,
|
|
835
|
+
validation: options.validation
|
|
836
|
+
});
|
|
518
837
|
resources.push(...result.resources);
|
|
838
|
+
diagnostics.push(...result.diagnostics);
|
|
519
839
|
}
|
|
520
|
-
return {
|
|
840
|
+
return {
|
|
841
|
+
diagnostics,
|
|
842
|
+
resources
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
async function lint(options) {
|
|
846
|
+
const dir = resolve(options.dir);
|
|
847
|
+
const diagnostics = [];
|
|
848
|
+
if (await findConfigFile(dir, WIKI_CONFIG_FILES)) diagnostics.push(...(await inspectWiki({
|
|
849
|
+
dir,
|
|
850
|
+
validation: options.validation
|
|
851
|
+
})).diagnostics);
|
|
852
|
+
if (await findConfigFile(dir, COLLECTION_CONFIG_FILES)) diagnostics.push(...(await inspectGuides({
|
|
853
|
+
dir,
|
|
854
|
+
validation: options.validation
|
|
855
|
+
})).diagnostics);
|
|
856
|
+
return { diagnostics };
|
|
521
857
|
}
|
|
522
858
|
//#endregion
|
|
523
859
|
//#region src/validate.ts
|
|
@@ -641,4 +977,4 @@ async function watch(options) {
|
|
|
641
977
|
return watcher;
|
|
642
978
|
}
|
|
643
979
|
//#endregion
|
|
644
|
-
export { compile, compileGuides, compileWiki, parseCollectionConfig, parseWikiConfig, validateResources, watch };
|
|
980
|
+
export { CompileError, compile, compileGuides, compileWiki, isErrorDiagnostic, lint, pagePathToName, parseCollectionConfig, parseWikiConfig, validateResources, watch };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@topik/core",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.4",
|
|
4
4
|
"description": "Topik core library",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -20,20 +20,19 @@
|
|
|
20
20
|
"access": "public"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@markdoc/markdoc": "^0.5.7",
|
|
24
23
|
"ajv": "^8.20.0",
|
|
25
24
|
"ajv-formats": "^3.0.1",
|
|
26
25
|
"chokidar": "^5.0.0",
|
|
27
|
-
"yaml": "^2.
|
|
28
|
-
"zod": "^4.3
|
|
29
|
-
"@topik/schema": "0.1.0-alpha.
|
|
26
|
+
"yaml": "^2.9.0",
|
|
27
|
+
"zod": "^4.4.3",
|
|
28
|
+
"@topik/content-schema": "0.1.0-alpha.4",
|
|
29
|
+
"@topik/schema": "0.1.0-alpha.4"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@types/node": "
|
|
33
|
-
"@typescript/native-preview": "7.0.0-dev.
|
|
34
|
-
"typescript": "^6",
|
|
35
|
-
"vite-plus": "
|
|
36
|
-
"vitest": "npm:@voidzero-dev/vite-plus-test@latest"
|
|
32
|
+
"@types/node": "22.20.1",
|
|
33
|
+
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
|
34
|
+
"typescript": "^6.0.3",
|
|
35
|
+
"vite-plus": "0.2.4"
|
|
37
36
|
},
|
|
38
37
|
"scripts": {
|
|
39
38
|
"build": "vp pack",
|