@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 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 WikiNavNode = string | {
15
- group: string;
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
- tab: string;
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 Markdoc from "@markdoc/markdoc";
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 navNodeSchema = z.union([
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
- group: z.string().min(1).max(256),
28
- slug: z.string().min(1).max(256).optional(),
29
- icon: z.string().min(1).max(256).optional(),
30
- children: z.lazy(() => z.array(navNodeSchema)).default([])
31
- }),
41
+ type: z.literal("page"),
42
+ slug: wikiPagePath,
43
+ icon,
44
+ hidden: z.boolean().optional()
45
+ }).strict(),
32
46
  z.object({
33
- tab: z.string().min(1).max(256),
34
- slug: z.string().min(1).max(256).optional(),
35
- icon: z.string().min(1).max(256).optional(),
36
- children: z.lazy(() => z.array(navNodeSchema)).default([])
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
- href: z.string().max(2048).refine((v) => v.startsWith("https://") || v.startsWith("http://"), { message: "href must be an http or https URL" }),
40
- title: z.string().min(1).max(256),
41
- icon: z.string().min(1).max(256).optional()
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(63).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
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 = ["src", "href"];
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 = Markdoc.parse(source);
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: Markdoc.format(ast),
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 mediaType = MIME_BY_EXT[extMatch ? `.${extMatch[1].toLowerCase()}` : ""];
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 { resources: [] };
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, name);
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: `${config.id}-${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
- return { resources };
625
+ if (!hasCompileErrors(diagnostics)) diagnostics.push(...validateWikiLinks(pageAnalyses, linkValidationPolicy(options.validation)));
626
+ return {
627
+ diagnostics,
628
+ resources
629
+ };
386
630
  }
387
- function collectPagePaths(nodes) {
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 ("group" in node || "tab" in node) paths.push(...collectPagePaths(node.children));
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
- return pagePath.replace(/^\//, "").replaceAll("/", "-");
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") return {
413
- type: "page",
414
- page: `${wikiId}-${pagePathToName(node)}`,
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: isTab ? "tab" : "group",
422
- title,
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
- children: resolveNavigation(node.children, wikiId)
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 { resources: [] };
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 { resources };
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({ dir });
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({ dir });
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 { resources };
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",
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.8.3",
28
- "zod": "^4.3.6",
29
- "@topik/schema": "0.1.0-alpha.3"
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": "^24",
33
- "@typescript/native-preview": "7.0.0-dev.20260427.1",
34
- "typescript": "^6",
35
- "vite-plus": "^0.1.18",
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",