@topik/core 0.1.0-alpha.2 → 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 +64 -82
- package/dist/index.mjs +607 -57
- package/package.json +16 -11
package/dist/index.d.mts
CHANGED
|
@@ -1,141 +1,123 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { Guide, Wiki, WikiPage } from "@topik/schema";
|
|
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<{
|
|
6
7
|
id: z.ZodString;
|
|
7
8
|
title: z.ZodString;
|
|
8
|
-
tags: z.ZodOptional<z.ZodArray<z.ZodString
|
|
9
|
-
},
|
|
10
|
-
id: string;
|
|
11
|
-
title: string;
|
|
12
|
-
tags?: string[] | undefined;
|
|
13
|
-
}, {
|
|
14
|
-
id: string;
|
|
15
|
-
title: string;
|
|
16
|
-
tags?: string[] | undefined;
|
|
17
|
-
}>;
|
|
9
|
+
tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
10
|
+
}, z.core.$strip>;
|
|
18
11
|
type CollectionConfig = z.infer<typeof collectionConfigSchema>;
|
|
19
12
|
declare function parseCollectionConfig(raw: unknown): CollectionConfig;
|
|
20
13
|
//#endregion
|
|
21
14
|
//#region src/config/wiki.d.ts
|
|
22
|
-
type
|
|
23
|
-
|
|
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;
|
|
24
24
|
slug?: string;
|
|
25
25
|
icon?: string;
|
|
26
|
+
expanded?: boolean;
|
|
27
|
+
hidden?: boolean;
|
|
26
28
|
children: WikiNavNode[];
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
+
};
|
|
30
|
+
type WikiInternalSwitcherNavNode = {
|
|
31
|
+
type: "tab" | "dropdown";
|
|
32
|
+
title: string;
|
|
29
33
|
slug?: string;
|
|
30
34
|
icon?: string;
|
|
35
|
+
hidden?: boolean;
|
|
31
36
|
children: WikiNavNode[];
|
|
32
|
-
}
|
|
37
|
+
};
|
|
38
|
+
type WikiExternalSwitcherNavNode = {
|
|
39
|
+
type: "tab" | "dropdown";
|
|
40
|
+
title: string;
|
|
33
41
|
href: string;
|
|
42
|
+
icon?: string;
|
|
43
|
+
hidden?: boolean;
|
|
44
|
+
};
|
|
45
|
+
type WikiLinkNavNode = {
|
|
46
|
+
type: "link";
|
|
34
47
|
title: string;
|
|
48
|
+
href: string;
|
|
35
49
|
icon?: string;
|
|
50
|
+
hidden?: boolean;
|
|
36
51
|
};
|
|
52
|
+
type WikiNavNode = string | WikiPageNavNode | WikiGroupNavNode | WikiInternalSwitcherNavNode | WikiExternalSwitcherNavNode | WikiLinkNavNode;
|
|
37
53
|
declare const wikiConfigSchema: z.ZodObject<{
|
|
38
54
|
id: z.ZodString;
|
|
39
55
|
title: z.ZodString;
|
|
40
|
-
|
|
56
|
+
description: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNull]>>;
|
|
57
|
+
navigation: z.ZodOptional<z.ZodArray<z.ZodType<WikiNavNode, unknown, z.core.$ZodTypeInternals<WikiNavNode, unknown>>>>;
|
|
41
58
|
theme: z.ZodOptional<z.ZodObject<{
|
|
42
59
|
colors: z.ZodOptional<z.ZodObject<{
|
|
43
60
|
primary: z.ZodString;
|
|
44
61
|
light: z.ZodOptional<z.ZodString>;
|
|
45
62
|
dark: z.ZodOptional<z.ZodString>;
|
|
46
|
-
},
|
|
47
|
-
primary: string;
|
|
48
|
-
light?: string | undefined;
|
|
49
|
-
dark?: string | undefined;
|
|
50
|
-
}, {
|
|
51
|
-
primary: string;
|
|
52
|
-
light?: string | undefined;
|
|
53
|
-
dark?: string | undefined;
|
|
54
|
-
}>>;
|
|
63
|
+
}, z.core.$strip>>;
|
|
55
64
|
appearance: z.ZodOptional<z.ZodObject<{
|
|
56
|
-
default: z.ZodOptional<z.ZodEnum<
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}>>;
|
|
62
|
-
},
|
|
63
|
-
|
|
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
|
-
}>;
|
|
65
|
+
default: z.ZodOptional<z.ZodEnum<{
|
|
66
|
+
dark: "dark";
|
|
67
|
+
light: "light";
|
|
68
|
+
system: "system";
|
|
69
|
+
}>>;
|
|
70
|
+
}, z.core.$strip>>;
|
|
71
|
+
}, z.core.$strip>>;
|
|
72
|
+
}, z.core.$strip>;
|
|
110
73
|
type WikiConfig = z.infer<typeof wikiConfigSchema>;
|
|
111
74
|
declare function parseWikiConfig(raw: unknown): WikiConfig;
|
|
112
75
|
//#endregion
|
|
113
76
|
//#region src/resource.d.ts
|
|
114
|
-
type Resource = Guide | Wiki | WikiPage;
|
|
77
|
+
type Resource = Asset | Guide | Wiki | WikiPage;
|
|
115
78
|
type ResourceType = Resource["type"];
|
|
116
79
|
//#endregion
|
|
117
80
|
//#region src/compile/shared.d.ts
|
|
118
81
|
interface CompileResult {
|
|
82
|
+
diagnostics: TopikContentDiagnostic[];
|
|
119
83
|
resources: Resource[];
|
|
120
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;
|
|
121
95
|
//#endregion
|
|
122
96
|
//#region src/compile/wiki.d.ts
|
|
123
97
|
interface CompileWikiOptions {
|
|
124
98
|
dir: string;
|
|
99
|
+
validation?: CompileValidationOptions;
|
|
125
100
|
}
|
|
126
101
|
declare function compileWiki(options: CompileWikiOptions): Promise<CompileResult>;
|
|
102
|
+
declare function pagePathToName(wikiId: string, pagePath: string): string;
|
|
127
103
|
//#endregion
|
|
128
104
|
//#region src/compile/guide.d.ts
|
|
129
105
|
interface CompileGuidesOptions {
|
|
130
106
|
dir: string;
|
|
107
|
+
validation?: CompileValidationOptions;
|
|
131
108
|
}
|
|
132
109
|
declare function compileGuides(options: CompileGuidesOptions): Promise<CompileResult>;
|
|
133
110
|
//#endregion
|
|
134
111
|
//#region src/compile/index.d.ts
|
|
135
112
|
interface CompileOptions {
|
|
136
113
|
dir: string;
|
|
114
|
+
validation?: CompileValidationOptions;
|
|
137
115
|
}
|
|
138
116
|
declare function compile(options: CompileOptions): Promise<CompileResult>;
|
|
117
|
+
interface LintResult {
|
|
118
|
+
diagnostics: CompileResult["diagnostics"];
|
|
119
|
+
}
|
|
120
|
+
declare function lint(options: CompileOptions): Promise<LintResult>;
|
|
139
121
|
//#endregion
|
|
140
122
|
//#region src/validate.d.ts
|
|
141
123
|
interface ValidationError {
|
|
@@ -168,4 +150,4 @@ interface Watcher {
|
|
|
168
150
|
}
|
|
169
151
|
declare function watch(options: WatchOptions): Promise<Watcher>;
|
|
170
152
|
//#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 };
|
|
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,10 +1,12 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { WIKI_EXTERNAL_HREF_PATTERN, WIKI_NAV_ICON_PATTERN, assetSchema, guideSchema, joinWikiPath, wikiPageSchema, wikiSchema } from "@topik/schema";
|
|
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";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { analyzeTopikContent, formatTopikContent, parseTopikContent, validateTopikContent } from "@topik/content-schema";
|
|
5
8
|
import Ajv2020 from "ajv/dist/2020.js";
|
|
6
9
|
import addFormats from "ajv-formats";
|
|
7
|
-
import { guideSchema, wikiPageSchema, wikiSchema } from "@topik/schema";
|
|
8
10
|
import { EventEmitter } from "node:events";
|
|
9
11
|
import { watch as watch$1 } from "chokidar";
|
|
10
12
|
//#region src/config/collection.ts
|
|
@@ -18,27 +20,125 @@ function parseCollectionConfig(raw) {
|
|
|
18
20
|
}
|
|
19
21
|
//#endregion
|
|
20
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");
|
|
21
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 '/'");
|
|
22
|
-
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([
|
|
23
39
|
wikiPagePath,
|
|
24
40
|
z.object({
|
|
25
|
-
|
|
26
|
-
slug:
|
|
27
|
-
icon
|
|
28
|
-
|
|
29
|
-
}),
|
|
41
|
+
type: z.literal("page"),
|
|
42
|
+
slug: wikiPagePath,
|
|
43
|
+
icon,
|
|
44
|
+
hidden: z.boolean().optional()
|
|
45
|
+
}).strict(),
|
|
46
|
+
z.object({
|
|
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(),
|
|
55
|
+
z.object({
|
|
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(),
|
|
30
63
|
z.object({
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
64
|
+
type: z.union([z.literal("tab"), z.literal("dropdown")]),
|
|
65
|
+
title,
|
|
66
|
+
href,
|
|
67
|
+
icon,
|
|
68
|
+
hidden: z.boolean().optional()
|
|
69
|
+
}).strict(),
|
|
36
70
|
z.object({
|
|
37
|
-
|
|
38
|
-
title
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
+
}
|
|
42
142
|
const hexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/, "Must be a hex color (e.g. #ff73a8)");
|
|
43
143
|
const themeSchema = z.object({
|
|
44
144
|
colors: z.object({
|
|
@@ -52,11 +152,23 @@ const themeSchema = z.object({
|
|
|
52
152
|
"system"
|
|
53
153
|
]).optional() }).optional()
|
|
54
154
|
});
|
|
155
|
+
const nameRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
156
|
+
const MAX_WIKI_ID_FOR_PAGE_NAMES = 46;
|
|
55
157
|
const wikiConfigSchema = z.object({
|
|
56
|
-
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),
|
|
57
159
|
title: z.string().min(1).max(256),
|
|
160
|
+
description: z.union([z.string().max(1024), z.null()]).optional(),
|
|
58
161
|
navigation: z.array(navNodeSchema).optional(),
|
|
59
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
|
+
}
|
|
60
172
|
});
|
|
61
173
|
function parseWikiConfig(raw) {
|
|
62
174
|
return wikiConfigSchema.parse(raw);
|
|
@@ -91,7 +203,222 @@ async function findConfigFile(dir, candidates) {
|
|
|
91
203
|
return null;
|
|
92
204
|
}
|
|
93
205
|
//#endregion
|
|
206
|
+
//#region src/compile/assets.ts
|
|
207
|
+
const MIME_BY_EXT = {
|
|
208
|
+
".png": "image/png",
|
|
209
|
+
".jpg": "image/jpeg",
|
|
210
|
+
".jpeg": "image/jpeg",
|
|
211
|
+
".gif": "image/gif",
|
|
212
|
+
".svg": "image/svg+xml",
|
|
213
|
+
".webp": "image/webp",
|
|
214
|
+
".avif": "image/avif",
|
|
215
|
+
".ico": "image/x-icon",
|
|
216
|
+
".bmp": "image/bmp",
|
|
217
|
+
".mp4": "video/mp4",
|
|
218
|
+
".webm": "video/webm",
|
|
219
|
+
".pdf": "application/pdf",
|
|
220
|
+
".zip": "application/zip",
|
|
221
|
+
".tar": "application/x-tar",
|
|
222
|
+
".gz": "application/gzip",
|
|
223
|
+
".mp3": "audio/mpeg",
|
|
224
|
+
".wav": "audio/wav",
|
|
225
|
+
".ogg": "audio/ogg",
|
|
226
|
+
".m4a": "audio/mp4",
|
|
227
|
+
".flac": "audio/flac",
|
|
228
|
+
".csv": "text/csv",
|
|
229
|
+
".txt": "text/plain",
|
|
230
|
+
".doc": "application/msword",
|
|
231
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
232
|
+
".xls": "application/vnd.ms-excel",
|
|
233
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
234
|
+
".ppt": "application/vnd.ms-powerpoint",
|
|
235
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
|
236
|
+
};
|
|
237
|
+
const ASSET_TAG_ATTRS = [
|
|
238
|
+
"src",
|
|
239
|
+
"darkSrc",
|
|
240
|
+
"href"
|
|
241
|
+
];
|
|
242
|
+
async function extractAssets(source, options) {
|
|
243
|
+
const { baseDir, filePath } = options;
|
|
244
|
+
const ast = parseTopikContent(source);
|
|
245
|
+
const refs = /* @__PURE__ */ new Map();
|
|
246
|
+
const slots = /* @__PURE__ */ new Map();
|
|
247
|
+
const collect = (url, slot, requireKnownExtension) => {
|
|
248
|
+
if (!isLocalRef(url)) return;
|
|
249
|
+
if (requireKnownExtension && !hasKnownAssetExtension(url)) return;
|
|
250
|
+
if (!refs.has(url)) {
|
|
251
|
+
const absPath = resolveAssetPath(url, {
|
|
252
|
+
baseDir,
|
|
253
|
+
filePath
|
|
254
|
+
});
|
|
255
|
+
const relFromBase = relative(baseDir, absPath).split(sep).join(posix.sep);
|
|
256
|
+
refs.set(url, {
|
|
257
|
+
original: url,
|
|
258
|
+
absPath,
|
|
259
|
+
relFromBase
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
const list = slots.get(url) ?? [];
|
|
263
|
+
list.push(slot);
|
|
264
|
+
slots.set(url, list);
|
|
265
|
+
};
|
|
266
|
+
walk(ast, (node) => {
|
|
267
|
+
if (node.type === "image") {
|
|
268
|
+
const url = stringAttr(node, "src");
|
|
269
|
+
if (url) collect(url, {
|
|
270
|
+
node,
|
|
271
|
+
attr: "src"
|
|
272
|
+
}, false);
|
|
273
|
+
} else if (node.type === "link") {
|
|
274
|
+
const url = stringAttr(node, "href");
|
|
275
|
+
if (url) collect(url, {
|
|
276
|
+
node,
|
|
277
|
+
attr: "href"
|
|
278
|
+
}, true);
|
|
279
|
+
} else if (node.type === "tag") for (const attr of ASSET_TAG_ATTRS) {
|
|
280
|
+
const url = stringAttr(node, attr);
|
|
281
|
+
if (url) collect(url, {
|
|
282
|
+
node,
|
|
283
|
+
attr
|
|
284
|
+
}, true);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
if (refs.size === 0) return {
|
|
288
|
+
content: source,
|
|
289
|
+
assets: [],
|
|
290
|
+
manifest: []
|
|
291
|
+
};
|
|
292
|
+
const urlToId = /* @__PURE__ */ new Map();
|
|
293
|
+
const byName = /* @__PURE__ */ new Map();
|
|
294
|
+
const refList = Array.from(refs.values());
|
|
295
|
+
const settled = await Promise.allSettled(refList.map((ref) => toAsset(ref)));
|
|
296
|
+
const failures = [];
|
|
297
|
+
for (let i = 0; i < settled.length; i++) {
|
|
298
|
+
const result = settled[i];
|
|
299
|
+
if (result.status === "fulfilled") {
|
|
300
|
+
urlToId.set(refList[i].original, result.value.name);
|
|
301
|
+
byName.set(result.value.name, result.value);
|
|
302
|
+
} else failures.push(result.reason instanceof Error ? result.reason.message : String(result.reason));
|
|
303
|
+
}
|
|
304
|
+
if (failures.length > 0) throw new Error(`Failed to extract ${failures.length} asset(s):\n - ${failures.join("\n - ")}`);
|
|
305
|
+
const manifest = [];
|
|
306
|
+
const seen = /* @__PURE__ */ new Set();
|
|
307
|
+
for (const ref of refs.values()) {
|
|
308
|
+
const name = urlToId.get(ref.original);
|
|
309
|
+
if (name && !seen.has(name)) {
|
|
310
|
+
manifest.push(name);
|
|
311
|
+
seen.add(name);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
for (const [url, slotList] of slots.entries()) {
|
|
315
|
+
const id = urlToId.get(url);
|
|
316
|
+
if (!id) continue;
|
|
317
|
+
for (const slot of slotList) slot.node.attributes[slot.attr] = `asset:${id}`;
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
content: formatTopikContent(ast),
|
|
321
|
+
assets: Array.from(byName.values()),
|
|
322
|
+
manifest
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
function walk(node, fn) {
|
|
326
|
+
fn(node);
|
|
327
|
+
for (const child of node.children) walk(child, fn);
|
|
328
|
+
}
|
|
329
|
+
function stringAttr(node, attr) {
|
|
330
|
+
const value = node.attributes?.[attr];
|
|
331
|
+
return typeof value === "string" ? value : void 0;
|
|
332
|
+
}
|
|
333
|
+
function hasKnownAssetExtension(url) {
|
|
334
|
+
const [path] = splitUrl(url);
|
|
335
|
+
const decoded = decodeSafe(path);
|
|
336
|
+
const m = /\.([a-z0-9]+)$/i.exec(decoded);
|
|
337
|
+
if (!m) return false;
|
|
338
|
+
return `.${m[1].toLowerCase()}` in MIME_BY_EXT;
|
|
339
|
+
}
|
|
340
|
+
function isLocalRef(url) {
|
|
341
|
+
if (url.length === 0) return false;
|
|
342
|
+
if (url.startsWith("#")) return false;
|
|
343
|
+
if (url.startsWith("mailto:")) return false;
|
|
344
|
+
if (url.startsWith("tel:")) return false;
|
|
345
|
+
if (url.startsWith("data:")) return false;
|
|
346
|
+
if (url.startsWith("//")) return false;
|
|
347
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(url)) return false;
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
function resolveAssetPath(url, { baseDir, filePath }) {
|
|
351
|
+
const [pathPart] = splitUrl(url);
|
|
352
|
+
const decoded = decodeSafe(pathPart);
|
|
353
|
+
const absolute = resolve(isAbsolute(decoded) || decoded.startsWith("/") ? baseDir : dirname(filePath), decoded.replace(/^\/+/, ""));
|
|
354
|
+
const rel = relative(baseDir, absolute);
|
|
355
|
+
if (rel.startsWith("..") || isAbsolute(rel)) throw new Error(`Asset reference "${url}" in ${filePath} resolves outside the compilation directory`);
|
|
356
|
+
return absolute;
|
|
357
|
+
}
|
|
358
|
+
function splitUrl(url) {
|
|
359
|
+
const hashIdx = url.indexOf("#");
|
|
360
|
+
const queryIdx = url.indexOf("?");
|
|
361
|
+
let cut = -1;
|
|
362
|
+
if (hashIdx >= 0) cut = hashIdx;
|
|
363
|
+
if (queryIdx >= 0 && (cut < 0 || queryIdx < cut)) cut = queryIdx;
|
|
364
|
+
if (cut < 0) return [url, ""];
|
|
365
|
+
return [url.slice(0, cut), url.slice(cut)];
|
|
366
|
+
}
|
|
367
|
+
function decodeSafe(s) {
|
|
368
|
+
try {
|
|
369
|
+
return decodeURIComponent(s);
|
|
370
|
+
} catch {
|
|
371
|
+
return s;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
async function toAsset(ref) {
|
|
375
|
+
let bytes;
|
|
376
|
+
try {
|
|
377
|
+
bytes = await readFile(ref.absPath);
|
|
378
|
+
} catch (error) {
|
|
379
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
380
|
+
throw new Error(`Asset "${ref.original}" not found at ${ref.absPath}: ${message}`, { cause: error });
|
|
381
|
+
}
|
|
382
|
+
const digest = createHash("sha256").update(bytes).digest();
|
|
383
|
+
const integrity = `sha256-${digest.toString("base64")}`;
|
|
384
|
+
const name = digest.toString("hex").slice(0, 16);
|
|
385
|
+
const extMatch = /\.([a-z0-9]+)(?:[?#].*)?$/i.exec(ref.relFromBase);
|
|
386
|
+
const ext = extMatch ? `.${extMatch[1].toLowerCase()}` : "";
|
|
387
|
+
const mediaType = MIME_BY_EXT[ext];
|
|
388
|
+
return {
|
|
389
|
+
apiVersion: "v1",
|
|
390
|
+
type: "Asset",
|
|
391
|
+
name,
|
|
392
|
+
spec: {
|
|
393
|
+
uri: ref.relFromBase,
|
|
394
|
+
integrity,
|
|
395
|
+
...mediaType ? { mediaType } : {}
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
//#endregion
|
|
94
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
|
+
}
|
|
95
422
|
const DNS_LABEL_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
96
423
|
function parseMarkdownFrontmatter(raw, filePath) {
|
|
97
424
|
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
@@ -121,6 +448,11 @@ function extractMarkdownTitle(content, fallback) {
|
|
|
121
448
|
if (match) return match[1].trim();
|
|
122
449
|
return fallback.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
123
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
|
+
}
|
|
124
456
|
function parseReferenceList(value, fieldName, filePath) {
|
|
125
457
|
if (value == null) return;
|
|
126
458
|
if (!Array.isArray(value)) throw new Error(`${fieldName} in ${filePath} must be an array of resource names`);
|
|
@@ -132,58 +464,177 @@ function parseReferenceList(value, fieldName, filePath) {
|
|
|
132
464
|
return references.length > 0 ? references : void 0;
|
|
133
465
|
}
|
|
134
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
|
|
135
543
|
//#region src/compile/wiki.ts
|
|
136
544
|
async function compileWiki(options) {
|
|
545
|
+
const result = await inspectWiki(options);
|
|
546
|
+
throwOnCompileErrors(result.diagnostics);
|
|
547
|
+
return result;
|
|
548
|
+
}
|
|
549
|
+
async function inspectWiki(options) {
|
|
137
550
|
const dir = resolve(options.dir);
|
|
138
551
|
const raw = await readOptionalConfigFile(dir, [
|
|
139
552
|
"wiki.yaml",
|
|
140
553
|
"wiki.yml",
|
|
141
554
|
"wiki.json"
|
|
142
555
|
]);
|
|
143
|
-
if (raw == null) return {
|
|
556
|
+
if (raw == null) return {
|
|
557
|
+
diagnostics: [],
|
|
558
|
+
resources: []
|
|
559
|
+
};
|
|
144
560
|
const config = parseWikiConfig(raw);
|
|
145
561
|
const pagePaths = config.navigation ? [...new Set(collectPagePaths(config.navigation))] : [];
|
|
146
|
-
const
|
|
147
|
-
|
|
562
|
+
const resolvedFiles = await Promise.all(pagePaths.map(async (pagePath) => {
|
|
563
|
+
const filePath = await resolveFilePath(dir, pagePath);
|
|
564
|
+
return {
|
|
565
|
+
filePath,
|
|
566
|
+
raw: await readFile(filePath, "utf-8")
|
|
567
|
+
};
|
|
148
568
|
}));
|
|
149
569
|
const resources = [];
|
|
570
|
+
const diagnostics = [];
|
|
571
|
+
const pageAnalyses = [];
|
|
572
|
+
const assetsById = /* @__PURE__ */ new Map();
|
|
150
573
|
for (let i = 0; i < pagePaths.length; i++) {
|
|
151
574
|
const pagePath = pagePaths[i];
|
|
152
|
-
const {
|
|
153
|
-
const
|
|
154
|
-
const
|
|
575
|
+
const { filePath, raw } = resolvedFiles[i];
|
|
576
|
+
const { frontmatter, content } = parseMarkdownFrontmatter(raw, pagePath);
|
|
577
|
+
const validation = validateTopikContent(content, { file: filePath });
|
|
578
|
+
diagnostics.push(...validation.errors);
|
|
579
|
+
if (!validation.valid) continue;
|
|
580
|
+
const { content: rewritten, assets, manifest } = await extractAssets(content, {
|
|
581
|
+
baseDir: dir,
|
|
582
|
+
filePath
|
|
583
|
+
});
|
|
584
|
+
for (const asset of assets) if (!assetsById.has(asset.name)) assetsById.set(asset.name, asset);
|
|
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
|
+
});
|
|
155
595
|
const pageResource = {
|
|
156
596
|
apiVersion: "v1",
|
|
157
597
|
type: "WikiPage",
|
|
158
|
-
name
|
|
598
|
+
name,
|
|
159
599
|
spec: {
|
|
160
600
|
wiki: config.id,
|
|
161
601
|
title,
|
|
602
|
+
...description != null ? { description } : {},
|
|
162
603
|
content: {
|
|
163
604
|
format: "topik",
|
|
164
|
-
value:
|
|
165
|
-
}
|
|
605
|
+
value: rewritten
|
|
606
|
+
},
|
|
607
|
+
...manifest.length > 0 ? { assets: manifest } : {}
|
|
166
608
|
}
|
|
167
609
|
};
|
|
168
610
|
resources.push(pageResource);
|
|
169
611
|
}
|
|
612
|
+
for (const asset of assetsById.values()) resources.push(asset);
|
|
170
613
|
const wikiResource = {
|
|
171
614
|
apiVersion: "v1",
|
|
172
615
|
type: "Wiki",
|
|
173
616
|
name: config.id,
|
|
174
617
|
spec: {
|
|
175
618
|
title: config.title,
|
|
619
|
+
...config.description != null ? { description: config.description } : {},
|
|
176
620
|
...config.navigation ? { navigation: resolveNavigation(config.navigation, config.id) } : {},
|
|
177
621
|
...config.theme ? { theme: config.theme } : {}
|
|
178
622
|
}
|
|
179
623
|
};
|
|
180
624
|
resources.push(wikiResource);
|
|
181
|
-
|
|
625
|
+
if (!hasCompileErrors(diagnostics)) diagnostics.push(...validateWikiLinks(pageAnalyses, linkValidationPolicy(options.validation)));
|
|
626
|
+
return {
|
|
627
|
+
diagnostics,
|
|
628
|
+
resources
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
function normalizeWikiPageDescription(description) {
|
|
632
|
+
return typeof description === "string" ? description.slice(0, 1024) : void 0;
|
|
182
633
|
}
|
|
183
|
-
function collectPagePaths(nodes) {
|
|
634
|
+
function collectPagePaths(nodes, prefix = "") {
|
|
184
635
|
const paths = [];
|
|
185
|
-
for (const node of nodes) if (typeof node === "string") paths.push(node);
|
|
186
|
-
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)));
|
|
187
638
|
return paths;
|
|
188
639
|
}
|
|
189
640
|
async function resolveFilePath(dir, pagePath) {
|
|
@@ -200,56 +651,124 @@ function pagePathToSlug(pagePath) {
|
|
|
200
651
|
if (pagePath === "index") return "";
|
|
201
652
|
return pagePath.replace(/\/index$/, "");
|
|
202
653
|
}
|
|
203
|
-
function pagePathToName(pagePath) {
|
|
204
|
-
|
|
654
|
+
function pagePathToName(wikiId, pagePath) {
|
|
655
|
+
const normalizedPath = normalizePagePath(pagePath);
|
|
656
|
+
return `${wikiId}-${createHash("sha256").update(normalizedPath).digest("hex").slice(0, 16)}`;
|
|
205
657
|
}
|
|
206
|
-
function resolveNavigation(nodes, wikiId) {
|
|
658
|
+
function resolveNavigation(nodes, wikiId, prefix = "") {
|
|
207
659
|
return nodes.map((node) => {
|
|
208
|
-
if (typeof node === "string")
|
|
209
|
-
|
|
210
|
-
|
|
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;
|
|
660
|
+
if (typeof node === "string" || node.type === "page") {
|
|
661
|
+
const localPath = typeof node === "string" ? node : node.slug;
|
|
662
|
+
const pagePath = joinWikiPath(prefix, localPath);
|
|
216
663
|
return {
|
|
217
|
-
type:
|
|
218
|
-
|
|
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,
|
|
686
|
+
...node.slug ? { slug: node.slug } : {},
|
|
687
|
+
...node.icon ? { icon: node.icon } : {},
|
|
688
|
+
...node.hidden ? { hidden: true } : {},
|
|
689
|
+
children
|
|
690
|
+
};
|
|
691
|
+
return {
|
|
692
|
+
type: "dropdown",
|
|
693
|
+
title: node.title,
|
|
219
694
|
...node.slug ? { slug: node.slug } : {},
|
|
220
695
|
...node.icon ? { icon: node.icon } : {},
|
|
221
|
-
|
|
696
|
+
...node.hidden ? { hidden: true } : {},
|
|
697
|
+
children
|
|
222
698
|
};
|
|
223
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
|
+
};
|
|
224
714
|
return {
|
|
225
715
|
type: "link",
|
|
226
716
|
title: node.title,
|
|
227
717
|
href: node.href,
|
|
228
|
-
...node.icon ? { icon: node.icon } : {}
|
|
718
|
+
...node.icon ? { icon: node.icon } : {},
|
|
719
|
+
...node.hidden ? { hidden: true } : {}
|
|
229
720
|
};
|
|
230
721
|
});
|
|
231
722
|
}
|
|
723
|
+
function normalizePagePath(pagePath) {
|
|
724
|
+
return pagePath.replace(/^\//, "").replace(/\.(?:mdx?|markdown)$/i, "");
|
|
725
|
+
}
|
|
726
|
+
function pagePathToTitleFallback(pagePath) {
|
|
727
|
+
return normalizePagePath(pagePath).replaceAll("/", "-");
|
|
728
|
+
}
|
|
232
729
|
//#endregion
|
|
233
730
|
//#region src/compile/guide.ts
|
|
234
731
|
async function compileGuides(options) {
|
|
732
|
+
const result = await inspectGuides(options);
|
|
733
|
+
throwOnCompileErrors(result.diagnostics);
|
|
734
|
+
return result;
|
|
735
|
+
}
|
|
736
|
+
async function inspectGuides(options) {
|
|
235
737
|
const dir = resolve(options.dir);
|
|
236
738
|
const raw = await readOptionalConfigFile(dir, [
|
|
237
739
|
"collection.yaml",
|
|
238
740
|
"collection.yml",
|
|
239
741
|
"collection.json"
|
|
240
742
|
]);
|
|
241
|
-
if (raw == null) return {
|
|
743
|
+
if (raw == null) return {
|
|
744
|
+
diagnostics: [],
|
|
745
|
+
resources: []
|
|
746
|
+
};
|
|
242
747
|
const config = parseCollectionConfig(raw);
|
|
243
748
|
const markdownFiles = (await readdir(dir)).filter((f) => f.endsWith(".md") || f.endsWith(".mdx")).sort();
|
|
244
749
|
const resources = [];
|
|
750
|
+
const diagnostics = [];
|
|
751
|
+
const assetsById = /* @__PURE__ */ new Map();
|
|
245
752
|
for (const file of markdownFiles) {
|
|
246
|
-
const
|
|
753
|
+
const filePath = join(dir, file);
|
|
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;
|
|
758
|
+
const { content: rewritten, assets, manifest } = await extractAssets(content, {
|
|
759
|
+
baseDir: dir,
|
|
760
|
+
filePath
|
|
761
|
+
});
|
|
762
|
+
for (const asset of assets) if (!assetsById.has(asset.name)) assetsById.set(asset.name, asset);
|
|
247
763
|
const slug = fileToSlug(file);
|
|
248
764
|
const name = `${config.id}-${slug}`;
|
|
249
|
-
const title = typeof frontmatter.title === "string" ? frontmatter.title : extractMarkdownTitle(
|
|
765
|
+
const title = typeof frontmatter.title === "string" ? frontmatter.title : extractMarkdownTitle(rewritten, slug);
|
|
250
766
|
const tags = mergeTags(config.tags, frontmatter.tags);
|
|
251
767
|
const authors = parseReferenceList(frontmatter.authors, "authors", file);
|
|
252
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)));
|
|
253
772
|
const guide = {
|
|
254
773
|
apiVersion: "v1",
|
|
255
774
|
type: "Guide",
|
|
@@ -262,13 +781,18 @@ async function compileGuides(options) {
|
|
|
262
781
|
...tags.length > 0 ? { tags } : {},
|
|
263
782
|
content: {
|
|
264
783
|
format: "topik",
|
|
265
|
-
value:
|
|
266
|
-
}
|
|
784
|
+
value: rewritten
|
|
785
|
+
},
|
|
786
|
+
...manifest.length > 0 ? { assets: manifest } : {}
|
|
267
787
|
}
|
|
268
788
|
};
|
|
269
789
|
resources.push(guide);
|
|
270
790
|
}
|
|
271
|
-
|
|
791
|
+
for (const asset of assetsById.values()) resources.push(asset);
|
|
792
|
+
return {
|
|
793
|
+
diagnostics,
|
|
794
|
+
resources
|
|
795
|
+
};
|
|
272
796
|
}
|
|
273
797
|
function fileToSlug(filename) {
|
|
274
798
|
return filename.replace(/\.(mdx?|md)$/, "");
|
|
@@ -296,15 +820,40 @@ const COLLECTION_CONFIG_FILES = [
|
|
|
296
820
|
async function compile(options) {
|
|
297
821
|
const dir = resolve(options.dir);
|
|
298
822
|
const resources = [];
|
|
823
|
+
const diagnostics = [];
|
|
299
824
|
if (await findConfigFile(dir, WIKI_CONFIG_FILES)) {
|
|
300
|
-
const result = await compileWiki({
|
|
825
|
+
const result = await compileWiki({
|
|
826
|
+
dir,
|
|
827
|
+
validation: options.validation
|
|
828
|
+
});
|
|
301
829
|
resources.push(...result.resources);
|
|
830
|
+
diagnostics.push(...result.diagnostics);
|
|
302
831
|
}
|
|
303
832
|
if (await findConfigFile(dir, COLLECTION_CONFIG_FILES)) {
|
|
304
|
-
const result = await compileGuides({
|
|
833
|
+
const result = await compileGuides({
|
|
834
|
+
dir,
|
|
835
|
+
validation: options.validation
|
|
836
|
+
});
|
|
305
837
|
resources.push(...result.resources);
|
|
838
|
+
diagnostics.push(...result.diagnostics);
|
|
306
839
|
}
|
|
307
|
-
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 };
|
|
308
857
|
}
|
|
309
858
|
//#endregion
|
|
310
859
|
//#region src/validate.ts
|
|
@@ -314,6 +863,7 @@ const ajv = new Ajv2020({
|
|
|
314
863
|
});
|
|
315
864
|
addFormats(ajv);
|
|
316
865
|
const validators = {
|
|
866
|
+
Asset: ajv.compile(assetSchema),
|
|
317
867
|
Guide: ajv.compile(guideSchema),
|
|
318
868
|
Wiki: ajv.compile(wikiSchema),
|
|
319
869
|
WikiPage: ajv.compile(wikiPageSchema)
|
|
@@ -427,4 +977,4 @@ async function watch(options) {
|
|
|
427
977
|
return watcher;
|
|
428
978
|
}
|
|
429
979
|
//#endregion
|
|
430
|
-
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,8 +1,13 @@
|
|
|
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
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/subtopik/topik.git",
|
|
9
|
+
"directory": "packages/core"
|
|
10
|
+
},
|
|
6
11
|
"files": [
|
|
7
12
|
"dist"
|
|
8
13
|
],
|
|
@@ -15,19 +20,19 @@
|
|
|
15
20
|
"access": "public"
|
|
16
21
|
},
|
|
17
22
|
"dependencies": {
|
|
18
|
-
"ajv": "^8.
|
|
23
|
+
"ajv": "^8.20.0",
|
|
19
24
|
"ajv-formats": "^3.0.1",
|
|
20
|
-
"chokidar": "^
|
|
21
|
-
"yaml": "^2.
|
|
22
|
-
"zod": "^
|
|
23
|
-
"@topik/schema": "0.1.0-alpha.
|
|
25
|
+
"chokidar": "^5.0.0",
|
|
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"
|
|
24
30
|
},
|
|
25
31
|
"devDependencies": {
|
|
26
|
-
"@types/node": "
|
|
27
|
-
"@typescript/native-preview": "7.0.0-dev.
|
|
28
|
-
"typescript": "^
|
|
29
|
-
"vite-plus": "
|
|
30
|
-
"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"
|
|
31
36
|
},
|
|
32
37
|
"scripts": {
|
|
33
38
|
"build": "vp pack",
|