@pterodoc/core 0.2.0

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.
Files changed (130) hide show
  1. package/LICENCE.md +10 -0
  2. package/README.md +12 -0
  3. package/lib/chunks/capture-BhdvHwrN.js +428 -0
  4. package/lib/chunks/capture-BhdvHwrN.js.map +1 -0
  5. package/lib/chunks/issues-DBsPh6ek.js +55 -0
  6. package/lib/chunks/issues-DBsPh6ek.js.map +1 -0
  7. package/lib/chunks/mime-2SUyOolJ.js +62 -0
  8. package/lib/chunks/mime-2SUyOolJ.js.map +1 -0
  9. package/lib/chunks/paths-Bh6UsRI9.js +177 -0
  10. package/lib/chunks/paths-Bh6UsRI9.js.map +1 -0
  11. package/lib/config/load.d.ts +123 -0
  12. package/lib/config/load.d.ts.map +1 -0
  13. package/lib/config/types.d.ts +136 -0
  14. package/lib/config/types.d.ts.map +1 -0
  15. package/lib/errors.d.ts +53 -0
  16. package/lib/errors.d.ts.map +1 -0
  17. package/lib/index.d.ts +23 -0
  18. package/lib/index.d.ts.map +1 -0
  19. package/lib/index.js +807 -0
  20. package/lib/index.js.map +1 -0
  21. package/lib/model/capture.d.ts +37 -0
  22. package/lib/model/capture.d.ts.map +1 -0
  23. package/lib/model/index.d.ts +13 -0
  24. package/lib/model/index.d.ts.map +1 -0
  25. package/lib/model/index.js +7 -0
  26. package/lib/model/index.js.map +1 -0
  27. package/lib/model/reader.d.ts +25 -0
  28. package/lib/model/reader.d.ts.map +1 -0
  29. package/lib/model/tree.d.ts +81 -0
  30. package/lib/model/tree.d.ts.map +1 -0
  31. package/lib/model/types.d.ts +166 -0
  32. package/lib/model/types.d.ts.map +1 -0
  33. package/lib/render/admonitions.d.ts +26 -0
  34. package/lib/render/admonitions.d.ts.map +1 -0
  35. package/lib/render/blocks.d.ts +39 -0
  36. package/lib/render/blocks.d.ts.map +1 -0
  37. package/lib/render/code.d.ts +37 -0
  38. package/lib/render/code.d.ts.map +1 -0
  39. package/lib/render/components.d.ts +49 -0
  40. package/lib/render/components.d.ts.map +1 -0
  41. package/lib/render/excerpt.d.ts +9 -0
  42. package/lib/render/excerpt.d.ts.map +1 -0
  43. package/lib/render/images.d.ts +58 -0
  44. package/lib/render/images.d.ts.map +1 -0
  45. package/lib/render/index.d.ts +80 -0
  46. package/lib/render/index.d.ts.map +1 -0
  47. package/lib/render/index.js +1336 -0
  48. package/lib/render/index.js.map +1 -0
  49. package/lib/render/inline.d.ts +25 -0
  50. package/lib/render/inline.d.ts.map +1 -0
  51. package/lib/render/links.d.ts +45 -0
  52. package/lib/render/links.d.ts.map +1 -0
  53. package/lib/render/mdx.d.ts +37 -0
  54. package/lib/render/mdx.d.ts.map +1 -0
  55. package/lib/render/page.d.ts +85 -0
  56. package/lib/render/page.d.ts.map +1 -0
  57. package/lib/render/parse.d.ts +27 -0
  58. package/lib/render/parse.d.ts.map +1 -0
  59. package/lib/render/references.d.ts +20 -0
  60. package/lib/render/references.d.ts.map +1 -0
  61. package/lib/render/renderers.d.ts +57 -0
  62. package/lib/render/renderers.d.ts.map +1 -0
  63. package/lib/render/slug.d.ts +34 -0
  64. package/lib/render/slug.d.ts.map +1 -0
  65. package/lib/render/theme.d.ts +62 -0
  66. package/lib/render/theme.d.ts.map +1 -0
  67. package/lib/sync/artifacts.d.ts +46 -0
  68. package/lib/sync/artifacts.d.ts.map +1 -0
  69. package/lib/sync/plan.d.ts +59 -0
  70. package/lib/sync/plan.d.ts.map +1 -0
  71. package/lib/sync/run.d.ts +39 -0
  72. package/lib/sync/run.d.ts.map +1 -0
  73. package/lib/target/index.d.ts +8 -0
  74. package/lib/target/index.d.ts.map +1 -0
  75. package/lib/target/index.js +2 -0
  76. package/lib/target/index.js.map +1 -0
  77. package/lib/target/target.d.ts +152 -0
  78. package/lib/target/target.d.ts.map +1 -0
  79. package/lib/util/hash.d.ts +10 -0
  80. package/lib/util/hash.d.ts.map +1 -0
  81. package/lib/util/index.d.ts +14 -0
  82. package/lib/util/index.d.ts.map +1 -0
  83. package/lib/util/index.js +7 -0
  84. package/lib/util/index.js.map +1 -0
  85. package/lib/util/issues.d.ts +52 -0
  86. package/lib/util/issues.d.ts.map +1 -0
  87. package/lib/util/mime.d.ts +16 -0
  88. package/lib/util/mime.d.ts.map +1 -0
  89. package/lib/util/paths.d.ts +42 -0
  90. package/lib/util/paths.d.ts.map +1 -0
  91. package/lib/version.d.ts +6 -0
  92. package/lib/version.d.ts.map +1 -0
  93. package/package.json +43 -0
  94. package/src/config/load.ts +387 -0
  95. package/src/config/types.ts +137 -0
  96. package/src/errors.ts +77 -0
  97. package/src/globals.d.ts +10 -0
  98. package/src/index.ts +31 -0
  99. package/src/model/capture.ts +92 -0
  100. package/src/model/index.ts +25 -0
  101. package/src/model/reader.ts +37 -0
  102. package/src/model/tree.ts +416 -0
  103. package/src/model/types.ts +167 -0
  104. package/src/render/admonitions.ts +74 -0
  105. package/src/render/blocks.ts +79 -0
  106. package/src/render/code.ts +112 -0
  107. package/src/render/components.ts +230 -0
  108. package/src/render/excerpt.ts +16 -0
  109. package/src/render/images.ts +113 -0
  110. package/src/render/index.ts +129 -0
  111. package/src/render/inline.ts +62 -0
  112. package/src/render/links.ts +94 -0
  113. package/src/render/mdx.ts +178 -0
  114. package/src/render/page.ts +289 -0
  115. package/src/render/parse.ts +65 -0
  116. package/src/render/references.ts +69 -0
  117. package/src/render/renderers.ts +346 -0
  118. package/src/render/slug.ts +63 -0
  119. package/src/render/theme.ts +92 -0
  120. package/src/sync/artifacts.ts +70 -0
  121. package/src/sync/plan.ts +69 -0
  122. package/src/sync/run.ts +641 -0
  123. package/src/target/index.ts +18 -0
  124. package/src/target/target.ts +146 -0
  125. package/src/util/hash.ts +14 -0
  126. package/src/util/index.ts +23 -0
  127. package/src/util/issues.ts +86 -0
  128. package/src/util/mime.ts +47 -0
  129. package/src/util/paths.ts +101 -0
  130. package/src/version.ts +31 -0
@@ -0,0 +1,146 @@
1
+ /**
2
+ * What a publishing target has to be able to do.
3
+ *
4
+ * WordPress is the only implementation today, but keeping the contract
5
+ * explicit is what stops target vocabulary leaking into the renderer and the
6
+ * reconciler.
7
+ */
8
+
9
+ /** What a target can and cannot do. */
10
+ export interface TargetCapabilities {
11
+ /** Page ids must exist before bodies can be rendered, as WordPress's page list needs. */
12
+ needsIdsBeforeRender: boolean;
13
+ /** Files can be uploaded and referenced. */
14
+ supportsMedia: boolean;
15
+ /** Pages with no source can be removed. */
16
+ supportsPrune: boolean;
17
+ /** Pages nest; a flat target gets its whole path as one name. */
18
+ supportsHierarchy: boolean;
19
+ /** There is a separate summary field. */
20
+ supportsExcerpt: boolean;
21
+ /** Arbitrary key/value metadata can be written. */
22
+ supportsMeta: boolean;
23
+ /** A page can be given a template. */
24
+ supportsTemplates: boolean;
25
+ /** A page can be created unpublished. */
26
+ supportsDrafts: boolean;
27
+ }
28
+
29
+ /** A page as it exists on the target. */
30
+ export interface RemotePage {
31
+ id: number;
32
+ parent: number;
33
+ slug: string;
34
+ status: string;
35
+ link: string;
36
+ title: string;
37
+ content?: string;
38
+ excerpt?: string;
39
+ menuOrder: number;
40
+ template: string;
41
+ meta?: Record<string, unknown> | undefined;
42
+ }
43
+
44
+ /** A page as pterodoc would publish it. */
45
+ export interface RenderedPage {
46
+ /** Path within the published tree. */
47
+ path: string;
48
+ /** Slug of the last path segment. */
49
+ slug: string;
50
+ /** Page title. */
51
+ title: string;
52
+ /** The composed body. */
53
+ content: string;
54
+ /** Short summary. */
55
+ excerpt: string;
56
+ /** Position among siblings. */
57
+ menuOrder: number;
58
+ /** Metadata to write, when the target supports it. */
59
+ meta: Record<string, string>;
60
+ /** Source file, for messages. */
61
+ file?: string | undefined;
62
+ /** Version this page belongs to. */
63
+ versionName?: string | undefined;
64
+ }
65
+
66
+ /** A file to upload. */
67
+ export interface MediaUpload {
68
+ bytes: Uint8Array;
69
+ filename: string;
70
+ hash: string;
71
+ mime: string;
72
+ alt: string;
73
+ title: string;
74
+ }
75
+
76
+ /** An uploaded file. */
77
+ export interface MediaRef {
78
+ id: number;
79
+ hash: string;
80
+ url: string;
81
+ filename: string;
82
+ mime: string;
83
+ }
84
+
85
+ /** Asking the target to make sure a page exists. */
86
+ export interface EnsureRequest {
87
+ path: string;
88
+ slug: string;
89
+ parentId: number | null;
90
+ title: string;
91
+ menuOrder: number;
92
+ isRoot: boolean;
93
+ }
94
+
95
+ /** What came of that. */
96
+ export interface EnsureResult {
97
+ id: number | null;
98
+ created: boolean;
99
+ warnings: string[];
100
+ }
101
+
102
+ /** An open connection to a target. */
103
+ export interface TargetSession {
104
+ /** Every page under the target's namespace, fetched once. */
105
+ loadIndex(): Promise<RemotePage[]>;
106
+ /**
107
+ * Make sure everything above the documentation root exists.
108
+ *
109
+ * The target owns this because only it knows what a path is made of. Pages
110
+ * it has to create are reported so the plan can show them; pages that were
111
+ * already there are never touched.
112
+ */
113
+ ensureRootParent(): Promise<{ id: number | null; created: { path: string; id: number | null }[] }>;
114
+ /** Make sure a page exists at this position, and give back its id. */
115
+ ensurePage(request: EnsureRequest): Promise<EnsureResult>;
116
+ /** Fetch a page with the fields needed to compare it. */
117
+ fetchPage(id: number): Promise<RemotePage>;
118
+ /** Which fields of an existing page differ from the rendered one. */
119
+ diffPage(remote: RemotePage, rendered: RenderedPage, parentId: number): string[];
120
+ /** Write a rendered page, returning warnings for anything the target refused. */
121
+ writePage(id: number, page: RenderedPage, parentId: number): Promise<{ warnings: string[] }>;
122
+ /** Pages below a root that no rendered page accounts for, deepest first. */
123
+ computePrune(index: RemotePage[], rootId: number, keepIds: Set<number>): RemotePage[];
124
+ /** Remove a page. Never a permanent delete. */
125
+ removePage(page: RemotePage): Promise<void>;
126
+ /** Files already uploaded, by content hash. */
127
+ loadMediaIndex(): Promise<Map<string, MediaRef>>;
128
+ /** Upload one file. */
129
+ uploadMedia(upload: MediaUpload): Promise<MediaRef>;
130
+ /** How many requests this session has made. */
131
+ requestCount(): number;
132
+ }
133
+
134
+ /** A place documentation can be published. */
135
+ export interface Target {
136
+ /** Name used in messages and in the plan. */
137
+ readonly name: string;
138
+ /** What this target can do. */
139
+ readonly capabilities: TargetCapabilities;
140
+ /** Absolute site path for a tree path: the target owns URL policy. */
141
+ hrefFor(treePath: string, context: { versionName: string; locale: string }): string;
142
+ /** The path pages are published under, for messages. */
143
+ readonly rootPath: string;
144
+ /** Open a session. */
145
+ open(context: { locale: string; dryRun: boolean }): Promise<TargetSession>;
146
+ }
@@ -0,0 +1,14 @@
1
+ /** Content hashing, used to give an uploaded asset a stable identity. */
2
+
3
+ import { createHash } from 'node:crypto';
4
+
5
+ /**
6
+ * A short content hash.
7
+ *
8
+ * Sixteen hex characters of SHA-256: long enough that a collision across one
9
+ * site's assets is not a practical concern, short enough to sit inside a
10
+ * WordPress slug without dominating it.
11
+ */
12
+ export function contentHash(bytes: Uint8Array | string): string {
13
+ return createHash('sha256').update(bytes).digest('hex').slice(0, 16);
14
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Shared helpers with no domain knowledge, and the errors every layer throws.
3
+ *
4
+ * Nothing here imports another layer, which is what makes it safe for all of
5
+ * them to import it.
6
+ */
7
+
8
+ export { EXIT, PterodocError, ConfigError, TargetError, UnsupportedContentError } from '../errors';
9
+ export { VERSION, USER_AGENT } from '../version';
10
+ export { contentHash } from './hash';
11
+ export { compareSeverity, formatIssue, IssueCollector } from './issues';
12
+ export type { Issue, Severity } from './issues';
13
+ export { mimeTypeFor, isBlockedByDefault } from './mime';
14
+ export {
15
+ joinPath,
16
+ relativeToPrefix,
17
+ resolveAliasedPath,
18
+ segments,
19
+ slugify,
20
+ titleCase,
21
+ toPosix,
22
+ toSlugSegments,
23
+ } from './paths';
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Structured diagnostics.
3
+ *
4
+ * Everything a run wants to tell the user goes through here rather than into
5
+ * free-form strings, so the CLI can group them, `--json` can emit them, and
6
+ * `--strict` can decide whether they should fail the run.
7
+ */
8
+
9
+ /** How much a reader should care. */
10
+ export type Severity = 'info' | 'warning' | 'error';
11
+
12
+ const ORDER: Record<Severity, number> = { info: 0, warning: 1, error: 2 };
13
+
14
+ /** One thing worth telling the user about. */
15
+ export interface Issue {
16
+ /** Stable identifier, e.g. `mdx-unknown-component`. Never localised. */
17
+ code: string;
18
+ /** How much a reader should care. */
19
+ severity: Severity;
20
+ /** One sentence, in English, naming what happened. */
21
+ message: string;
22
+ /** Source file the issue came from, relative to the site directory. */
23
+ file?: string | undefined;
24
+ /** One-based line within `file`. */
25
+ line?: number | undefined;
26
+ /** One-based column within `line`. */
27
+ column?: number | undefined;
28
+ /** Document id, when the issue belongs to a document. */
29
+ docId?: string | undefined;
30
+ /** Page path within the target tree, when the issue belongs to a page. */
31
+ path?: string | undefined;
32
+ }
33
+
34
+ /** Accumulates issues during a run. */
35
+ export class IssueCollector {
36
+ readonly issues: Issue[] = [];
37
+
38
+ /** Record an issue. */
39
+ add(issue: Issue): void {
40
+ this.issues.push(issue);
41
+ }
42
+
43
+ /** Record an issue that inherits a document's file and id. */
44
+ addFor(
45
+ doc: { id: string; sourceRelativePath?: string } | undefined,
46
+ issue: Omit<Issue, 'docId' | 'file'> & { file?: string },
47
+ ): void {
48
+ this.add({
49
+ ...issue,
50
+ docId: doc?.id,
51
+ file: issue.file ?? doc?.sourceRelativePath,
52
+ });
53
+ }
54
+
55
+ /** True when any issue is at least as severe as `severity`. */
56
+ hasAtLeast(severity: Severity): boolean {
57
+ return this.issues.some((issue) => ORDER[issue.severity] >= ORDER[severity]);
58
+ }
59
+
60
+ /** Issues at least as severe as `severity`. */
61
+ atLeast(severity: Severity): Issue[] {
62
+ return this.issues.filter((issue) => ORDER[issue.severity] >= ORDER[severity]);
63
+ }
64
+
65
+ /** How many issues carry each code, for a compact summary. */
66
+ countByCode(): Record<string, number> {
67
+ const counts: Record<string, number> = {};
68
+ for (const issue of this.issues) counts[issue.code] = (counts[issue.code] ?? 0) + 1;
69
+ return counts;
70
+ }
71
+ }
72
+
73
+ /** Compare two severities; positive when `a` is more severe than `b`. */
74
+ export function compareSeverity(a: Severity, b: Severity): number {
75
+ return ORDER[a] - ORDER[b];
76
+ }
77
+
78
+ /** Render an issue as one line, with its position when it has one. */
79
+ export function formatIssue(issue: Issue): string {
80
+ const where = issue.file
81
+ ? `${issue.file}${issue.line ? `:${issue.line}${issue.column ? `:${issue.column}` : ''}` : ''}: `
82
+ : issue.path
83
+ ? `${issue.path || '(root)'}: `
84
+ : '';
85
+ return `${where}${issue.message}`;
86
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Extension to media type.
3
+ *
4
+ * A twenty-entry table rather than a dependency: these are the types a
5
+ * documentation site actually embeds, and WordPress rejects most others.
6
+ */
7
+
8
+ const TYPES: Record<string, string> = {
9
+ '.apng': 'image/apng',
10
+ '.avif': 'image/avif',
11
+ '.bmp': 'image/bmp',
12
+ '.gif': 'image/gif',
13
+ '.ico': 'image/x-icon',
14
+ '.jpeg': 'image/jpeg',
15
+ '.jpg': 'image/jpeg',
16
+ '.png': 'image/png',
17
+ '.svg': 'image/svg+xml',
18
+ '.tif': 'image/tiff',
19
+ '.tiff': 'image/tiff',
20
+ '.webp': 'image/webp',
21
+ '.mp4': 'video/mp4',
22
+ '.webm': 'video/webm',
23
+ '.ogv': 'video/ogg',
24
+ '.mp3': 'audio/mpeg',
25
+ '.wav': 'audio/wav',
26
+ '.pdf': 'application/pdf',
27
+ '.zip': 'application/zip',
28
+ '.json': 'application/json',
29
+ '.txt': 'text/plain',
30
+ };
31
+
32
+ /** The media type for a file name, or undefined when we do not recognise it. */
33
+ export function mimeTypeFor(fileName: string): string | undefined {
34
+ const dot = fileName.lastIndexOf('.');
35
+ if (dot === -1) return undefined;
36
+ return TYPES[fileName.slice(dot).toLowerCase()];
37
+ }
38
+
39
+ /**
40
+ * True for types WordPress refuses by default.
41
+ *
42
+ * SVG is the one that surprises people: it is blocked unless a plugin allows
43
+ * it, because an SVG can carry script.
44
+ */
45
+ export function isBlockedByDefault(mime: string): boolean {
46
+ return mime === 'image/svg+xml';
47
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Path helpers shared by the source reader and the targets.
3
+ *
4
+ * Everything here is POSIX-shaped: a documentation tree is addressed by URL
5
+ * paths, not by the host's separators.
6
+ */
7
+
8
+ import path from 'node:path';
9
+ import { ConfigError } from '../errors';
10
+
11
+ /** Prefix Docusaurus uses for paths relative to the site directory. */
12
+ const SITE_ALIAS = '@site/';
13
+
14
+ /**
15
+ * Resolve a Docusaurus `@site/...` path to an absolute one.
16
+ *
17
+ * @param aliased The path as Docusaurus records it, e.g. `@site/docs/intro.md`.
18
+ * @param siteDir Absolute path of the Docusaurus site directory.
19
+ */
20
+ export function resolveAliasedPath(aliased: string, siteDir: string): string {
21
+ if (!aliased.startsWith(SITE_ALIAS)) {
22
+ throw new ConfigError(
23
+ `Expected a "@site/"-relative path from Docusaurus but got "${aliased}". This usually means an unsupported Docusaurus version.`,
24
+ );
25
+ }
26
+ return path.resolve(siteDir, aliased.slice(SITE_ALIAS.length));
27
+ }
28
+
29
+ /** Turn a host path into the POSIX form used for ids and messages. */
30
+ export function toPosix(value: string): string {
31
+ return value.split(path.sep).join('/');
32
+ }
33
+
34
+ /** Split a URL-ish path into its non-empty segments. */
35
+ export function segments(value: string): string[] {
36
+ return value.split('/').filter(Boolean);
37
+ }
38
+
39
+ /**
40
+ * Split a configured path into slug segments, rejecting anything WordPress
41
+ * would not accept as a page slug.
42
+ *
43
+ * @param value A path such as `/products/docstack`.
44
+ * @param label The setting's name, used in the error message.
45
+ */
46
+ export function toSlugSegments(value: string, label: string): string[] {
47
+ const parts = segments(String(value ?? '').trim());
48
+ for (const part of parts) {
49
+ if (!/^[a-z0-9]+(?:[-_][a-z0-9]+)*$/.test(part)) {
50
+ throw new ConfigError(
51
+ `${label} segment "${part}" is not a slug. Use lowercase letters, digits, hyphens and underscores.`,
52
+ );
53
+ }
54
+ }
55
+ return parts;
56
+ }
57
+
58
+ /**
59
+ * Make a slug out of arbitrary text, for path segments we derive ourselves
60
+ * (a version name, for instance) rather than receive from Docusaurus.
61
+ */
62
+ export function slugify(value: string): string {
63
+ return String(value)
64
+ .normalize('NFKD')
65
+ .replace(/[\u0300-\u036f]/g, '')
66
+ .toLowerCase()
67
+ .replace(/[^a-z0-9]+/g, '-')
68
+ .replace(/^-+|-+$/g, '') || 'untitled';
69
+ }
70
+
71
+ /** Title-case a slug, for a page that has no better label available. */
72
+ export function titleCase(slug: string): string {
73
+ return segments(slug.replace(/[-_]/g, ' ').replace(/\s+/g, ' '))
74
+ .join(' ')
75
+ .split(' ')
76
+ .filter(Boolean)
77
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
78
+ .join(' ');
79
+ }
80
+
81
+ /**
82
+ * The path a URL points at below a prefix.
83
+ *
84
+ * Returns undefined when `url` is not under `prefix`, which is how the link
85
+ * resolver decides whether a link belongs to the documentation at all.
86
+ */
87
+ export function relativeToPrefix(url: string, prefix: string): string | undefined {
88
+ const normalisedPrefix = `/${segments(prefix).join('/')}`;
89
+ const normalisedUrl = `/${segments(url).join('/')}`;
90
+ if (normalisedPrefix === '/') return segments(normalisedUrl).join('/');
91
+ if (normalisedUrl === normalisedPrefix) return '';
92
+ if (!normalisedUrl.startsWith(`${normalisedPrefix}/`)) return undefined;
93
+ return normalisedUrl.slice(normalisedPrefix.length + 1);
94
+ }
95
+
96
+ /** Join path segments into an absolute URL path with a trailing slash. */
97
+ export function joinPath(parts: string[], trailingSlash = true): string {
98
+ const joined = parts.flatMap((part) => segments(part)).join('/');
99
+ if (joined === '') return '/';
100
+ return trailingSlash ? `/${joined}/` : `/${joined}`;
101
+ }
package/src/version.ts ADDED
@@ -0,0 +1,31 @@
1
+ /** The tool's own version, stamped into the build. */
2
+
3
+ import fs from 'node:fs';
4
+
5
+ /**
6
+ * Read the version.
7
+ *
8
+ * The build replaces the token, so a built artefact never touches the disk and
9
+ * does not care how deeply it is nested. Running from source — tsx, the test
10
+ * suite, an editor — there is no token, and the manifest sits exactly one
11
+ * directory above this file.
12
+ *
13
+ * `typeof` on an undeclared identifier is the one expression that does not
14
+ * throw, so the ambient declaration costs nothing at runtime.
15
+ */
16
+ function readVersion(): string {
17
+ if (typeof __PTERODOC_VERSION__ === 'string') return __PTERODOC_VERSION__;
18
+ try {
19
+ const manifest = new URL('../package.json', import.meta.url);
20
+ const parsed = JSON.parse(fs.readFileSync(manifest, 'utf8')) as { version?: string };
21
+ return parsed.version ?? '0.0.0';
22
+ } catch {
23
+ return '0.0.0';
24
+ }
25
+ }
26
+
27
+ /** Semantic version of this package. */
28
+ export const VERSION = readVersion();
29
+
30
+ /** The User-Agent every outbound request identifies itself with. */
31
+ export const USER_AGENT = `pterodoc/${VERSION} (+https://github.com/onyx-ac/pterodoc)`;