@doc-kit/core 0.0.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 (75) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +36 -0
  4. package/package.json +88 -0
  5. package/shiki.config.mjs +50 -0
  6. package/src/caching.mjs +183 -0
  7. package/src/generators/ast/README.md +8 -0
  8. package/src/generators/ast/constants.mjs +1 -0
  9. package/src/generators/ast/generate.mjs +125 -0
  10. package/src/generators/ast/index.mjs +20 -0
  11. package/src/generators/ast/types.d.ts +7 -0
  12. package/src/generators/ast-js/README.md +8 -0
  13. package/src/generators/ast-js/generate.mjs +56 -0
  14. package/src/generators/ast-js/index.mjs +24 -0
  15. package/src/generators/ast-js/types.d.ts +5 -0
  16. package/src/generators/index.mjs +48 -0
  17. package/src/generators/json-simple/README.md +9 -0
  18. package/src/generators/json-simple/generate.mjs +33 -0
  19. package/src/generators/json-simple/index.mjs +23 -0
  20. package/src/generators/json-simple/types.d.ts +6 -0
  21. package/src/generators/loader.mjs +96 -0
  22. package/src/generators/metadata/README.md +8 -0
  23. package/src/generators/metadata/constants.mjs +68 -0
  24. package/src/generators/metadata/generate.mjs +40 -0
  25. package/src/generators/metadata/index.mjs +21 -0
  26. package/src/generators/metadata/maps/builtin.json +16 -0
  27. package/src/generators/metadata/maps/mdn.json +1114 -0
  28. package/src/generators/metadata/types.d.ts +145 -0
  29. package/src/generators/metadata/utils/parse.mjs +160 -0
  30. package/src/generators/metadata/utils/resolveTypes.mjs +204 -0
  31. package/src/generators/metadata/utils/slugger.mjs +39 -0
  32. package/src/generators/metadata/utils/transformers.mjs +109 -0
  33. package/src/generators/metadata/utils/visitors.mjs +134 -0
  34. package/src/generators/metadata/utils/yaml.mjs +58 -0
  35. package/src/generators/types.d.ts +129 -0
  36. package/src/generators.mjs +145 -0
  37. package/src/logger/constants.mjs +34 -0
  38. package/src/logger/index.mjs +27 -0
  39. package/src/logger/logger.mjs +177 -0
  40. package/src/logger/transports/console.mjs +54 -0
  41. package/src/logger/transports/github.mjs +42 -0
  42. package/src/logger/transports/index.mjs +11 -0
  43. package/src/logger/types.d.ts +29 -0
  44. package/src/logger/utils/colors.mjs +18 -0
  45. package/src/logger/utils/time.mjs +21 -0
  46. package/src/parsers/markdown.mjs +49 -0
  47. package/src/parsers/types.d.ts +7 -0
  48. package/src/threading/chunk-worker.mjs +23 -0
  49. package/src/threading/index.mjs +27 -0
  50. package/src/threading/parallel.mjs +140 -0
  51. package/src/utils/array.mjs +7 -0
  52. package/src/utils/configuration/index.mjs +263 -0
  53. package/src/utils/configuration/templates.mjs +21 -0
  54. package/src/utils/configuration/types.d.ts +64 -0
  55. package/src/utils/file.mjs +20 -0
  56. package/src/utils/generators.mjs +71 -0
  57. package/src/utils/highlighter.mjs +198 -0
  58. package/src/utils/html-minifier.mjs +12 -0
  59. package/src/utils/loaders.mjs +48 -0
  60. package/src/utils/misc.mjs +72 -0
  61. package/src/utils/queries/constants.mjs +3 -0
  62. package/src/utils/queries/index.mjs +93 -0
  63. package/src/utils/queries/utils.mjs +45 -0
  64. package/src/utils/remark.mjs +97 -0
  65. package/src/utils/signature/constants.mjs +12 -0
  66. package/src/utils/signature/parseList.mjs +133 -0
  67. package/src/utils/signature/parseSignature.mjs +208 -0
  68. package/src/utils/signature/types.d.ts +66 -0
  69. package/src/utils/swc.mjs +34 -0
  70. package/src/utils/type-annotations/hast.mjs +115 -0
  71. package/src/utils/type-annotations/mdast.mjs +75 -0
  72. package/src/utils/type-annotations/remark.mjs +24 -0
  73. package/src/utils/type-annotations/syntax.mjs +188 -0
  74. package/src/utils/unist.mjs +75 -0
  75. package/src/utils/url.mjs +27 -0
@@ -0,0 +1,263 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { cpus } from 'node:os';
4
+ import { dirname, isAbsolute, resolve } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { isMainThread } from 'node:worker_threads';
7
+
8
+ import { cosmiconfig } from 'cosmiconfig';
9
+ import { coerce } from 'semver';
10
+
11
+ import {
12
+ loadGenerators,
13
+ resolveGeneratorSpecifier,
14
+ } from '../../generators/loader.mjs';
15
+ import logger from '../../logger/index.mjs';
16
+ import { parseChangelog, parseIndex } from '../../parsers/markdown.mjs';
17
+ import { enforceArray } from '../array.mjs';
18
+ import { leftHandAssign } from '../generators.mjs';
19
+ import { deepMerge } from '../misc.mjs';
20
+
21
+ const configExplorer = cosmiconfig('doc-kit');
22
+
23
+ /**
24
+ * The name of the project being documented, from the manifest in the working
25
+ * directory. Generators use it for titles, logos, and templated text.
26
+ *
27
+ * @returns {string | undefined}
28
+ */
29
+ const detectProject = () => {
30
+ try {
31
+ return JSON.parse(readFileSync('package.json', 'utf-8')).name;
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ };
36
+
37
+ /**
38
+ * Get's the default configuration for the loaded generators
39
+ *
40
+ * @param {Map<string, GeneratorMetadata>} generators - Loaded generators
41
+ * @param {Partial<import('./types').Configuration>} config - The user configuration
42
+ */
43
+ export const getDefaultConfig = (generators, config) =>
44
+ [...generators.values()].reduce(
45
+ (acc, generator) => {
46
+ acc[generator.name] =
47
+ 'defaultConfiguration' in generator
48
+ ? typeof generator.defaultConfiguration === 'function'
49
+ ? generator.defaultConfiguration(config)
50
+ : generator.defaultConfiguration
51
+ : {};
52
+
53
+ return acc;
54
+ },
55
+ /** @type {import('./types').Configuration} */ ({
56
+ global: {
57
+ project: detectProject() ?? 'API Docs',
58
+ version: process.version,
59
+ minify: true,
60
+ ref: 'HEAD',
61
+ // Without release history there is nothing to build a version picker
62
+ // from, so generators render single-version output.
63
+ changelog: [],
64
+ pathsToCopy: ['assets', 'public', 'static'],
65
+ },
66
+
67
+ // The number of wasm memory instances is severely limited on
68
+ // riscv64 with sv39. Running multiple generators that use wasm in
69
+ // parallel could cause failures to allocate new wasm instance.
70
+ // See also https://github.com/nodejs/node/pull/60591
71
+ threads: process.arch === 'riscv64' ? 1 : cpus().length,
72
+ chunkSize: 10,
73
+ })
74
+ );
75
+
76
+ /**
77
+ * Resolves an `extends` entry of a configuration file into an importable
78
+ * URL: relative paths resolve against the configuration file, anything else
79
+ * resolves as a package import specifier (e.g. `@node-core/doc-kit/config`).
80
+ *
81
+ * @param {string} specifier - The `extends` entry
82
+ * @param {string} configFilePath - The configuration file it appears in
83
+ * @returns {string} A `file:` URL to import
84
+ */
85
+ const resolveConfigExtends = (specifier, configFilePath) => {
86
+ if (specifier.startsWith('.') || isAbsolute(specifier)) {
87
+ return pathToFileURL(resolve(dirname(configFilePath), specifier)).href;
88
+ }
89
+
90
+ return pathToFileURL(createRequire(configFilePath).resolve(specifier)).href;
91
+ };
92
+
93
+ /**
94
+ * Loads an explicit configuration file or searches for one using cosmiconfig.
95
+ *
96
+ * @param {string} [filePath] - The path to an explicit configuration file
97
+ * @returns {Promise<Partial<import('./types').Configuration>>} The loaded configuration object, or an empty object if none is found
98
+ */
99
+ export const loadConfigFile = async filePath => {
100
+ const result = filePath
101
+ ? await configExplorer.load(filePath)
102
+ : await configExplorer.search();
103
+
104
+ if (!result) {
105
+ return {};
106
+ }
107
+
108
+ let { extends: presets, ...config } = result.config ?? {};
109
+
110
+ for (const preset of enforceArray(presets ?? []).toReversed()) {
111
+ const module = await import(resolveConfigExtends(preset, result.filepath));
112
+
113
+ config = deepMerge(module.default ?? module, config);
114
+ }
115
+
116
+ return config;
117
+ };
118
+
119
+ /**
120
+ * Transforms configuration values that need async processing or coercion.
121
+ * Only processes values that haven't been transformed yet (strings for changelog/index, non-coerced versions).
122
+ *
123
+ * @param {import('./types').GlobalConfiguration} value
124
+ * @returns {Promise<import('./types').GlobalConfiguration>}
125
+ */
126
+ const transformConfig = async value => {
127
+ // Only coerce if it's a string (not already coerced)
128
+ if (value.version && typeof value.version === 'string') {
129
+ value.version = coerce(value.version);
130
+ }
131
+
132
+ // Only parse if it's not already parsed
133
+ if (value.changelog && !Array.isArray(value.changelog)) {
134
+ value.changelog = await parseChangelog(value.changelog);
135
+ }
136
+
137
+ // Only parse if it's not already parsed
138
+ if (value.index && !Array.isArray(value.index)) {
139
+ value.index = await parseIndex(value.index);
140
+ }
141
+
142
+ return value;
143
+ };
144
+
145
+ /**
146
+ * Converts CLI options into a config
147
+ * @param {import('../../../bin/commands/generate.mjs').CLIOptions} options
148
+ * @returns {import('./types').Configuration}
149
+ */
150
+ export const createConfigFromCLIOptions = options => ({
151
+ global: {
152
+ input: options.input,
153
+ ignore: options.ignore,
154
+ output: options.output,
155
+ minify: options.minify,
156
+ ref: options.gitRef,
157
+ version: options.version,
158
+ changelog: options.changelog,
159
+ index: options.index,
160
+ },
161
+ metadata: {
162
+ typeMap: options.typeMap,
163
+ },
164
+ target: options.target,
165
+ threads: options.threads,
166
+ chunkSize: options.chunkSize,
167
+ });
168
+
169
+ /**
170
+ * Asserts that the resolved configuration has everything needed to run:
171
+ * at least one generator `target` and an `input` to read source files from.
172
+ * These may come from CLI flags or a config file; by this point both sources
173
+ * have been merged, so we validate the result rather than the raw options.
174
+ *
175
+ * @param {import('./types').Configuration} config - The merged configuration
176
+ */
177
+ export const assertRunnableOptions = config => {
178
+ if (!config.target || !config.global?.input) {
179
+ throw new Error(
180
+ 'Both a `target` and an `input` must be provided, either via ' +
181
+ '`--target`/`--input` or a configuration file. ' +
182
+ 'Run `doc-kit generate --help` for usage.'
183
+ );
184
+ }
185
+ };
186
+
187
+ /**
188
+ * Creates a complete run configuration by merging config file, user options, and defaults.
189
+ * Processes and validates configuration values including version coercion, changelog parsing,
190
+ * and constraint enforcement for threads and chunk size.
191
+ *
192
+ * @param {import('../../../bin/commands/generate.mjs').CLIOptions} options - User-provided configuration options
193
+ * @returns {Promise<import('./types').Configuration>} The configuration
194
+ */
195
+ export const createRunConfiguration = async options => {
196
+ const config = await loadConfigFile(options.configFile);
197
+ config.target &&= enforceArray(config.target);
198
+
199
+ // Resolve user configuration first so dynamic defaults can use it
200
+ const cliConfig = createConfigFromCLIOptions(options);
201
+ const intermediate = deepMerge(config, cliConfig);
202
+
203
+ // Resolve shorthand targets into import specifiers, then load the requested
204
+ // generators (and their dependency closure) so their defaults can be applied
205
+ intermediate.target &&= intermediate.target.map(resolveGeneratorSpecifier);
206
+ const generators = await loadGenerators(intermediate.target ?? []);
207
+
208
+ const merged = deepMerge(
209
+ getDefaultConfig(generators, intermediate),
210
+ intermediate
211
+ );
212
+
213
+ // These need to be coerced
214
+ merged.threads = Math.max(merged.threads, 1);
215
+ merged.chunkSize = Math.max(merged.chunkSize, 1);
216
+
217
+ if (process.arch === 'riscv64' && merged.threads > 1) {
218
+ logger.warn(
219
+ `Using ${merged.threads} threads might cause failures when ` +
220
+ 'allocating wasm memory due to insufficient virtual address space ' +
221
+ 'on riscv64 with sv39. Please consider using only a single thread.'
222
+ );
223
+ }
224
+
225
+ // Transform global config if it wasn't already done
226
+ await transformConfig(merged.global);
227
+
228
+ // Now assign to each generator config (they inherit from global)
229
+ await Promise.all(
230
+ [...generators.values()].map(async ({ name }) => {
231
+ const value = merged[name];
232
+
233
+ // Transform generator-specific overrides
234
+ await transformConfig(value);
235
+
236
+ // Assign from global (this populates missing values from global)
237
+ leftHandAssign(value, merged.global);
238
+ })
239
+ );
240
+
241
+ return merged;
242
+ };
243
+
244
+ /** @type {import('./types').Configuration} */
245
+ let config;
246
+
247
+ /**
248
+ * Configuration setter
249
+ * @param {import('./types').Configuration | import('../../../bin/commands/generate.mjs').CLIOptions} options
250
+ * @returns {Promise<import('./types').Configuration>}
251
+ */
252
+ export const setConfig = async options =>
253
+ (config = isMainThread ? await createRunConfiguration(options) : options);
254
+
255
+ /**
256
+ * Configuration getter
257
+ * @template {keyof import('./types').Configuration} T
258
+ * @param {T} generator
259
+ * @returns {T extends keyof import('./types').Configuration ? import('./types').Configuration[T] : import('./types').Configuration}
260
+ */
261
+ const getConfig = generator => (generator ? config[generator] : config);
262
+
263
+ export default getConfig;
@@ -0,0 +1,21 @@
1
+ // This is the CHANGELOG to be consumed to generate a list of all major versions
2
+ export const CHANGELOG_URL =
3
+ 'https://raw.githubusercontent.com/{repository}/{ref}/CHANGELOG.md';
4
+
5
+ // This is the Base URL for viewing a file within GitHub UI
6
+ export const GITHUB_BLOB_URL = 'https://github.com/{repository}/blob/{ref}/';
7
+
8
+ // This is the API docs base URL for editing a file on GitHub UI
9
+ export const GITHUB_EDIT_URL = 'https://github.com/{repository}/edit/{ref}';
10
+
11
+ /**
12
+ * Populate a template string based on a configuration
13
+ * @param {string} template The template string
14
+ * @param {import("./types").GlobalConfiguration} config The configuration
15
+ * @returns {string} the populated string
16
+ */
17
+ export const populate = (template, config) =>
18
+ template.replace(
19
+ /\{(\w+)(?:\|(\w+))?\}/g,
20
+ (match, path, fallback) => config[path] ?? fallback ?? match
21
+ );
@@ -0,0 +1,64 @@
1
+ import type { SemVer } from 'semver';
2
+ import type { ReleaseEntry } from '../../parsers/types';
3
+
4
+ export type Configuration = {
5
+ global: GlobalConfiguration;
6
+
7
+ // A list of generators to be used in the API doc generation process;
8
+ // This is considered a "sorted" list of generators, in the sense that
9
+ // if the last entry of this list contains a generated value, we will return
10
+ // the value of the last generator in the list, if any.
11
+ target: Array<keyof AvailableGenerators>;
12
+
13
+ // The number of threads the process is allowed to use
14
+ threads: number;
15
+
16
+ // Number of items to process per worker thread
17
+ chunkSize: number;
18
+ } & {
19
+ [K in keyof AllGenerators]: GlobalConfiguration &
20
+ AllGenerators[K]['defaultConfiguration'];
21
+ };
22
+
23
+ export type GlobalConfiguration = {
24
+ // The name of the project being documented, used for titles, logos, and
25
+ // templated text (defaults to the `name` in the working directory's
26
+ // `package.json`)
27
+ project: string;
28
+
29
+ // The repository (`owner/name`), used for source and edit links; when
30
+ // omitted, repository-specific UI (e.g. the GitHub link) is omitted
31
+ repository?: string;
32
+
33
+ // The path to the input source files. This parameter accepts globs and can
34
+ // be a glob when passed to a generator.
35
+ input: string | string[];
36
+
37
+ // The path or glob patterns used to ignore files from the input source files.
38
+ ignore: string | string[];
39
+
40
+ // The path used to output generated files, this is to be considered
41
+ // the base path that any generator will use for generating files
42
+ // This parameter accepts globs but when passed to generators will contain
43
+ // the already resolved absolute path to the output folder
44
+ output: string;
45
+
46
+ // Whether or not the minify the output, in whatever form it may be
47
+ minify: boolean;
48
+
49
+ // Target Node.js version for the generation of the API docs
50
+ version: SemVer;
51
+
52
+ // A list of all major versions and their respective release information
53
+ changelog: Array<ReleaseEntry>;
54
+
55
+ // A list of all the titles of all the documentation files
56
+ index: Array<{ section: string; api: string }>;
57
+
58
+ // The base URL of the published site; templates referencing `{baseURL}`
59
+ // (e.g. sitemap and llms-txt page URLs) need it to produce absolute URLs
60
+ baseURL?: string | URL;
61
+
62
+ // Git ref (i.e. HEAD)
63
+ ref: string;
64
+ };
@@ -0,0 +1,20 @@
1
+ import fs from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+
4
+ /**
5
+ * Returns the input string with the `ext` extension, replacing any pre-existing extension
6
+ * @param {string} str
7
+ * @param {string} ext
8
+ */
9
+ export const withExt = (str, ext) =>
10
+ `${str.replace(/\.[0-9a-z]+$/i, '')}${ext ? `.${ext}` : ''}`;
11
+
12
+ /**
13
+ * Writes a file, recursively
14
+ *
15
+ * @type {typeof fs.writeFile}
16
+ */
17
+ export const writeFile = (file, ...args) =>
18
+ fs
19
+ .mkdir(dirname(file), { recursive: true })
20
+ .then(() => fs.writeFile(file, ...args));
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ import { coerce, major } from 'semver';
4
+
5
+ /**
6
+ * Groups all the API metadata nodes by module (`api` property) so that we can process each different file
7
+ * based on the module it belongs to.
8
+ *
9
+ * @param {Array<import('../generators/metadata/types').MetadataEntry>} nodes The API metadata Nodes to be grouped
10
+ */
11
+ export const groupNodesByModule = nodes => {
12
+ return Map.groupBy(nodes, node => node.api);
13
+ };
14
+
15
+ /**
16
+ * Parses the SemVer string into a Node.js-alike version
17
+ *
18
+ * @param {import('semver').SemVer} version The version to be parsed
19
+ */
20
+ export const getVersionFromSemVer = version =>
21
+ version.minor === 0
22
+ ? `${version.major}.x`
23
+ : `${version.major}.${version.minor}.x`;
24
+
25
+ /**
26
+ * @TODO: This should not be necessary, and indicates errors within the API docs
27
+ * @TODO: Hookup into a future Validation/Linting API
28
+ *
29
+ * This is a safe fallback to ensure that we always have a SemVer compatible version
30
+ * even if the input is not a valid SemVer string
31
+ *
32
+ * @param {string|import('semver').SemVer} version SemVer compatible version (maybe)
33
+ * @returns {import('semver').SemVer} SemVer compatible version
34
+ */
35
+ export const coerceSemVer = version => {
36
+ const coercedVersion = coerce(version);
37
+
38
+ if (coercedVersion === null) {
39
+ // @TODO: Linter to complain about invalid SemVer strings
40
+ return coerce('0.0.0-REPLACEME');
41
+ }
42
+
43
+ return coercedVersion;
44
+ };
45
+
46
+ /**
47
+ * Gets compatible versions for an entry
48
+ *
49
+ * @param {string | import('semver').SemVer} introduced
50
+ * @param {Array<import('../parsers/types').ReleaseEntry>} releases
51
+ * @returns {Array<import('../parsers/types').ReleaseEntry>}
52
+ */
53
+ export const getCompatibleVersions = (introduced, releases) => {
54
+ const coercedMajor = major(coerceSemVer(introduced));
55
+ // All Node.js versions that support the current API; If there's no "introduced_at" field,
56
+ // we simply show all versions, as we cannot pinpoint the exact version
57
+ return releases.filter(release => release.version.major >= coercedMajor);
58
+ };
59
+
60
+ /**
61
+ * Assigns properties from one or more source objects to the target object
62
+ * **without overwriting existing keys** in the target.
63
+ *
64
+ * Similar to `Object.assign`, but preserves the target's existing keys.
65
+ * The target object is mutated in place.
66
+ *
67
+ * @param {Object} target - The object to assign properties to.
68
+ * @param {Object} source - The source object
69
+ */
70
+ export const leftHandAssign = (target, source) =>
71
+ Object.keys(source).forEach(k => k in target || (target[k] = source[k]));
@@ -0,0 +1,198 @@
1
+ 'use strict';
2
+
3
+ import { endianness } from 'node:os';
4
+
5
+ import createHighlighter from '@node-core/rehype-shiki';
6
+ import { h as createElement } from 'hastscript';
7
+ import { SKIP, visit } from 'unist-util-visit';
8
+
9
+ import shikiConfig from '../../shiki.config.mjs';
10
+
11
+ // This is what Remark will use as prefix within a <pre> className
12
+ // to attribute the current language of the <pre> element
13
+ const languagePrefix = 'language-';
14
+
15
+ /**
16
+ * Creates a toolbar element with a language label and copy button.
17
+ *
18
+ * @param {string} languageId - The language identifier for the code block.
19
+ * @returns {import('hast').Element} The toolbar element.
20
+ */
21
+ function createToolbarElement(languageId) {
22
+ return createElement('div', { class: 'code-toolbar' }, [
23
+ createElement('span', { class: 'code-language' }, languageId),
24
+ createElement('button', { class: 'copy-button' }, 'copy'),
25
+ ]);
26
+ }
27
+
28
+ /**
29
+ * Checks if the given node is a valid code element.
30
+ *
31
+ * @param {import('unist').Node} node - The node to be verified.
32
+ *
33
+ * @return {boolean} - True when it is a valid code element, false otherwise.
34
+ */
35
+ function isCodeBlock(node) {
36
+ return Boolean(
37
+ node?.tagName === 'pre' && node?.children[0].tagName === 'code'
38
+ );
39
+ }
40
+
41
+ export const highlighter = await createHighlighter({
42
+ // riscv64 with sv39 has limited virtual memory space, where creating
43
+ // too many (>20) wasm memory instances fails.
44
+ // https://github.com/nodejs/node/pull/60591
45
+ //
46
+ // The wasm highlighter is currently not compatible with big endian.
47
+ // https://github.com/nodejs/node/pull/62512#issuecomment-4243469950
48
+ wasm: process.arch !== 'riscv64' && endianness() === 'LE',
49
+ });
50
+
51
+ /**
52
+ * Creates a HAST transformer for Shiki which is used for transforming our codeboxes
53
+ *
54
+ * @deprecated This is used only for the legacy-html generator, please use `@node-core/rehype-shiki` directly instead.
55
+ *
56
+ * @type {import('unified').Plugin}
57
+ */
58
+ export default function rehypeShikiji() {
59
+ /**
60
+ * @param {import('hast').Root} tree - The HAST tree to be transformed.
61
+ */
62
+ return function (tree) {
63
+ visit(tree, 'element', (node, index, parent) => {
64
+ // We only want to process <pre>...</pre> elements
65
+ if (!parent || index == null || node.tagName !== 'pre') {
66
+ return;
67
+ }
68
+
69
+ // We want the contents of the <pre> element, hence we attempt to get the first child
70
+ const preElement = node.children[0];
71
+
72
+ // If thereÄs nothing inside the <pre> element... What are we doing here?
73
+ if (!preElement || !preElement.properties) {
74
+ return;
75
+ }
76
+
77
+ // Ensure that we're not visiting a <code> element but it's inner contents
78
+ // (keep iterating further down until we reach where we want)
79
+ if (preElement.type !== 'element' || preElement.tagName !== 'code') {
80
+ return;
81
+ }
82
+
83
+ // Get the <pre> element class names
84
+ const preClassNames = preElement.properties.className;
85
+
86
+ // The current classnames should be an array and it should have a length
87
+ if (typeof preClassNames !== 'object' || preClassNames.length === 0) {
88
+ return;
89
+ }
90
+
91
+ // We want to retrieve the language class name from the class names
92
+ const codeLanguage = preClassNames.find(
93
+ c => typeof c === 'string' && c.startsWith(languagePrefix)
94
+ );
95
+
96
+ // If we didn't find any `language-` classname then we shouldn't highlight
97
+ if (typeof codeLanguage !== 'string') {
98
+ return;
99
+ }
100
+
101
+ // Grabs the relevant alias/name of the language
102
+ const languageId = codeLanguage.slice(languagePrefix.length);
103
+
104
+ // Parses the <pre> contents and returns a HAST tree with the highlighted code
105
+ const { children } = highlighter.shiki.codeToHast(
106
+ preElement.children[0].value,
107
+ {
108
+ lang: languageId,
109
+ // Allows support for dual themes (light, dark) for Shiki
110
+ themes: { light: shikiConfig.themes[0], dark: shikiConfig.themes[1] },
111
+ }
112
+ );
113
+
114
+ // Adds the original language back to the <pre> element
115
+ children[0].properties.class = `${children[0].properties.class} ${codeLanguage}`;
116
+
117
+ // Adds the toolbar (language label + copy button) to the <pre> element
118
+ children[0].children.push(createToolbarElement(languageId));
119
+
120
+ // Replaces the <pre> element with the updated one
121
+ parent.children.splice(index, 1, ...children);
122
+ });
123
+
124
+ visit(tree, 'element', (_, index, parent) => {
125
+ const codeElements = [];
126
+
127
+ let currentIndex = index;
128
+
129
+ while (isCodeBlock(parent?.children[currentIndex])) {
130
+ const preElement = parent?.children[currentIndex];
131
+ const codeElement = preElement.children[0];
132
+
133
+ // We should get the language name from the class name
134
+ if (preElement.properties.class?.length) {
135
+ const className = preElement.properties.class;
136
+ const matches = className.match(/language-(?<language>.*)/);
137
+ const language = matches?.groups.language ?? 'text';
138
+
139
+ // For this iteration of our code, we only support multi-code tab for
140
+ // JavaScript languages for Node.js (CJS / MJS)
141
+ if (language === 'cjs' || language === 'mjs') {
142
+ // We patch up Shiki's `pre` element properties into the `code` element
143
+ // since we want to keep the original properties
144
+ codeElement.properties.class = `${className} ${language}`;
145
+ codeElement.properties.style = preElement.properties.style;
146
+ codeElement.properties.language = language;
147
+
148
+ // Add the code element to the pre children
149
+ codeElements.push(codeElement);
150
+ }
151
+ }
152
+
153
+ currentIndex += 1;
154
+
155
+ // Since we only support CJS/MJS switch, we should have exactly 2 elements
156
+ // with different languages in order to create a switchable code tab
157
+ if (
158
+ codeElements.length === 2 &&
159
+ codeElements[0].properties?.language !==
160
+ codeElements[1].properties?.language
161
+ ) {
162
+ const switchablePreElement = createElement(
163
+ 'pre',
164
+ {
165
+ // We grab Shiki's styling from the code tag
166
+ // back to the pre element tag to ensure consistency
167
+ style: codeElements[0].properties.style,
168
+ class: 'shiki',
169
+ },
170
+ [
171
+ createElement('input', {
172
+ class: 'js-flavor-toggle',
173
+ type: 'checkbox',
174
+ // If the CJS code block is the first one, then we should keep
175
+ // the checkbox checked so that it highglit the CJS by default
176
+ checked: codeElements[0].properties.language === 'cjs',
177
+ }),
178
+ ...codeElements,
179
+ createToolbarElement('javascript'),
180
+ ]
181
+ );
182
+
183
+ // This removes all the original code Elements and adds a new CodeTab Element
184
+ // at the original start of the first Code Element
185
+ parent.children.splice(
186
+ index,
187
+ currentIndex - index,
188
+ switchablePreElement
189
+ );
190
+
191
+ // Prevent visiting the code block children and for the next N Elements
192
+ // since all of them belong to this CodeTabs Element
193
+ return [SKIP];
194
+ }
195
+ }
196
+ });
197
+ };
198
+ }
@@ -0,0 +1,12 @@
1
+ import { minify } from '@swc/html-wasm';
2
+
3
+ /**
4
+ * Minifies HTML with project defaults and optional overrides. At the moment,
5
+ * swc's defaults are suitable for our needs, but in the event that this changes,
6
+ * allowing project defaults is beneficial.
7
+ *
8
+ * @param {string} html
9
+ * @param {import('@swc/html-wasm').Options} [options]
10
+ */
11
+ export const minifyHTML = async (html, options = {}) =>
12
+ minify(html, options).then(({ code }) => code);