@heroiclands/package-build 0.1.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.
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The names a top-level statement would declare in global scope.
3
+ *
4
+ * Only declaration forms matter: an expression statement or a call declares
5
+ * nothing. Destructuring patterns are walked, so `const { a, b } = …` reports
6
+ * both names — a bundler emits those routinely, and missing them would let the
7
+ * check pass a bundle that does collide.
8
+ *
9
+ * @param {object} node - A top-level `Program.body` entry.
10
+ * @returns {string[]} Declared identifier names, empty when it declares none.
11
+ */
12
+ export function declaredGlobals(node: object): string[];
13
+ /**
14
+ * How a manifest declares an entry file.
15
+ *
16
+ * @param {object} manifest - The parsed manifest.
17
+ * @param {string} entry - The entry file's name, as the manifest spells it.
18
+ * @returns {"esmodules"|"scripts"|"both"|"neither"} Where it is declared.
19
+ */
20
+ export function entryDeclaration(manifest: object, entry: string): "esmodules" | "scripts" | "both" | "neither";
21
+ /**
22
+ * Every top-level declaration a source would create in global scope.
23
+ *
24
+ * Parses as a **classic script**, which is the only parse under which the
25
+ * question means anything.
26
+ *
27
+ * @param {string} source - The bundle's source text.
28
+ * @returns {Array<{name: string, line: number, kind: string}>} The declarations.
29
+ * @throws {SyntaxError} When the source does not parse as a script.
30
+ */
31
+ export function globalDeclarations(source: string): Array<{
32
+ name: string;
33
+ line: number;
34
+ kind: string;
35
+ }>;
36
+ /**
37
+ * Check that a manifest and the bundle it points at agree.
38
+ *
39
+ * @param {object} opts
40
+ * @param {object} opts.manifest - The parsed manifest.
41
+ * @param {string} opts.source - The bundle's source text.
42
+ * @param {string} opts.entry - The entry file's name, as the manifest spells it.
43
+ * @param {string} [opts.manifestName] - What to call the manifest in a message.
44
+ * @returns {{findings: Array<{line?: number, severity: "error", message: string}>,
45
+ * declaredAs: "esmodules"|"scripts"|"both"|"neither"}} The findings, empty
46
+ * when the two agree, and how the entry was declared.
47
+ */
48
+ export function checkBundleLoading({ manifest, source, entry, manifestName, }: {
49
+ manifest: object;
50
+ source: string;
51
+ entry: string;
52
+ manifestName?: string | undefined;
53
+ }): {
54
+ findings: Array<{
55
+ line?: number;
56
+ severity: "error";
57
+ message: string;
58
+ }>;
59
+ declaredAs: "esmodules" | "scripts" | "both" | "neither";
60
+ };
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Normalise a stage argument.
3
+ *
4
+ * @param {unknown} stageArg - Whatever the caller was given.
5
+ * @returns {string} The trimmed, lowercased stage name, `""` when absent.
6
+ */
7
+ export function resolveStage(stageArg: unknown): string;
8
+ /**
9
+ * Where a package installs beneath a Foundry data root.
10
+ *
11
+ * Foundry keeps systems and modules in sibling trees under `Data`, and the leaf
12
+ * is the package id — the same id the manifest declares and every compendium
13
+ * UUID starts with. Deriving it here is what lets one deploy serve a system and
14
+ * a module without either naming its own path.
15
+ *
16
+ * @param {"systems"|"modules"} packageKind - Which tree it installs into.
17
+ * @param {string} packageId - The Foundry package id.
18
+ * @returns {string[]} Path segments beneath the data root.
19
+ */
20
+ export function packageSubpath(packageKind: "systems" | "modules", packageId: string): string[];
21
+ /**
22
+ * Whether a destination names a remote host rather than a local directory.
23
+ *
24
+ * A remote target is `[user@]host:/path`. The colon is what distinguishes it —
25
+ * **except on Windows, where `C:\Foundry\Data` also has one**. A bare drive
26
+ * letter followed by a separator is therefore read as local; without that, a
27
+ * Windows developer's perfectly ordinary path is parsed as a host called `C`
28
+ * and the deploy fails trying to open an SSH connection to it.
29
+ *
30
+ * @param {string} target - The configured destination.
31
+ * @returns {boolean} True when it should be deployed over SFTP.
32
+ */
33
+ export function isRemoteTarget(target: string): boolean;
34
+ /**
35
+ * Parse a `[user@]host:/path` remote target into its parts.
36
+ *
37
+ * @param {string} target - The remote destination.
38
+ * @returns {{username: string|undefined, host: string, remotePath: string}}
39
+ */
40
+ export function parseRemote(target: string): {
41
+ username: string | undefined;
42
+ host: string;
43
+ remotePath: string;
44
+ };
45
+ /**
46
+ * Locate the SSH agent endpoint, cross-platform.
47
+ *
48
+ * Precedence: an explicit per-stage override (use `"pageant"` for PuTTY, or a
49
+ * named-pipe path), then `$SSH_AUTH_SOCK`, then the Windows OpenSSH agent's
50
+ * default named pipe. `undefined` when no agent is available, at which point a
51
+ * caller falls back to a key file.
52
+ *
53
+ * @param {NodeJS.ProcessEnv} env - The environment to read.
54
+ * @param {string} stageUpper - Uppercased stage name, e.g. `"QA"`.
55
+ * @param {string} [prefix] - Fallback variable prefix for a shared override.
56
+ * @returns {string|undefined} The agent endpoint.
57
+ */
58
+ export function resolveAgent(env: NodeJS.ProcessEnv, stageUpper: string, prefix?: string): string | undefined;
59
+ /**
60
+ * Assemble an `ssh2-sftp-client` connection config for a stage.
61
+ *
62
+ * Defaults to the SSH agent so no secret is read from disk. An explicit key
63
+ * *path* — not a secret — is the escape hatch; no passphrase or password is
64
+ * read from the environment, deliberately, so none ends up in a `.env` file.
65
+ *
66
+ * @param {string} stageUpper - Uppercased stage name, e.g. `"QA"`.
67
+ * @param {{username: string|undefined, host: string}} remote - Parsed target.
68
+ * @param {object} [opts]
69
+ * @param {NodeJS.ProcessEnv} [opts.env] - The environment to read.
70
+ * @param {string} [opts.prefix] - Fallback variable prefix.
71
+ * @returns {Promise<object>} The connection config.
72
+ */
73
+ export function buildConnection(stageUpper: string, remote: {
74
+ username: string | undefined;
75
+ host: string;
76
+ }, { env, prefix }?: {
77
+ env?: NodeJS.ProcessEnv | undefined;
78
+ prefix?: string | undefined;
79
+ }): Promise<object>;
80
+ /**
81
+ * Mirror the staged build into a local directory via a staged, atomic swap.
82
+ *
83
+ * The live destination is never mutated in place — see the module header for
84
+ * why that matters under a running server.
85
+ *
86
+ * @param {string} srcAbs - The staged tree.
87
+ * @param {string} destDir - Where the package installs.
88
+ * @returns {Promise<void>}
89
+ */
90
+ export function deployLocal(srcAbs: string, destDir: string): Promise<void>;
91
+ /**
92
+ * Mirror the staged build into a remote directory over SFTP, with the same
93
+ * staged swap {@link deployLocal} performs.
94
+ *
95
+ * `ssh2-sftp-client` is imported here rather than at module scope so that the
96
+ * pure helpers above — and a local deploy — cost nothing to import.
97
+ *
98
+ * @param {object} conn - Connection config from {@link buildConnection}.
99
+ * @param {string} srcAbs - The staged tree.
100
+ * @param {string} remoteDir - Where the package installs on the host.
101
+ * @param {object} [opts]
102
+ * @param {(uploaded: string) => void} [opts.onUpload] - Per-file progress.
103
+ * @returns {Promise<void>}
104
+ */
105
+ export function deployRemote(conn: object, srcAbs: string, remoteDir: string, { onUpload }?: {
106
+ onUpload?: ((uploaded: string) => void) | undefined;
107
+ }): Promise<void>;
108
+ /**
109
+ * Deploy a staged package to one stage, choosing the transport from the
110
+ * configured destination.
111
+ *
112
+ * @param {object} opts
113
+ * @param {string} opts.stage - `dev` / `qa` / `prod` / `test`.
114
+ * @param {string} opts.source - The staged tree.
115
+ * @param {"systems"|"modules"} opts.packageKind - Which Foundry tree.
116
+ * @param {string} opts.packageId - The Foundry package id.
117
+ * @param {NodeJS.ProcessEnv} [opts.env] - The environment to read.
118
+ * @param {string} [opts.prefix] - Fallback variable prefix for SFTP overrides.
119
+ * @param {(message: string) => void} [opts.log] - Progress reporting.
120
+ * @returns {Promise<{stage: string, destination: string, remote: boolean}>}
121
+ * @throws {Error} On an unknown stage, or one with no destination configured.
122
+ */
123
+ export function deployStage({ stage, source, packageKind, packageId, env, prefix, log, }: {
124
+ stage: string;
125
+ source: string;
126
+ packageKind: "systems" | "modules";
127
+ packageId: string;
128
+ env?: NodeJS.ProcessEnv | undefined;
129
+ prefix?: string | undefined;
130
+ log?: ((message: string) => void) | undefined;
131
+ }): Promise<{
132
+ stage: string;
133
+ destination: string;
134
+ remote: boolean;
135
+ }>;
136
+ /**
137
+ * The environment variable naming each stage's Foundry data root.
138
+ *
139
+ * The set is a shared convention rather than one repository's: every
140
+ * HeroicLands package deploys to the same four stages, and a `test` root is
141
+ * what the end-to-end harness seeds its throwaway world into.
142
+ */
143
+ export const STAGE_ENV_MAP: Readonly<{
144
+ dev: "FOUNDRYVTT_DEV_DATA";
145
+ qa: "FOUNDRYVTT_QA_DATA";
146
+ prod: "FOUNDRYVTT_PROD_DATA";
147
+ test: "FOUNDRYVTT_TEST_DATA";
148
+ }>;
@@ -0,0 +1,7 @@
1
+ export * as manifest from "./manifest.mjs";
2
+ export * as bundle from "./bundle.mjs";
3
+ export * as stage from "./stage.mjs";
4
+ export * as release from "./release.mjs";
5
+ export * as deploy from "./deploy.mjs";
6
+ export * as lang from "./lang.mjs";
7
+ export * as text from "./text.mjs";
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Every `[prefixKey, leafKey]` pair where `prefixKey` is a strict dotted prefix
3
+ * of `leafKey` and both are present as keys — the exact shape that makes
4
+ * `foundry.utils.expandObject` throw.
5
+ *
6
+ * @param {Record<string, unknown>} json - The parsed, flat localization object.
7
+ * @returns {[string, string][]} The colliding `[prefix, leaf]` pairs.
8
+ */
9
+ export function findPrefixCollisions(json: Record<string, unknown>): [string, string][];
10
+ /**
11
+ * Validate one localization file's source text.
12
+ *
13
+ * Findings are returned in file order where a position is known, so a caller
14
+ * that prints them walks the file top to bottom.
15
+ *
16
+ * @param {string} raw - The file's contents.
17
+ * @returns {LangFinding[]} Every finding, empty when the file is shippable.
18
+ */
19
+ export function validateLangSource(raw: string): LangFinding[];
20
+ /**
21
+ * A single finding, in the fields the shared diagnostic format takes.
22
+ *
23
+ * `file` is deliberately absent: these functions are handed source text, not a
24
+ * path, so the caller — which knows where the text came from — supplies it.
25
+ */
26
+ export type LangFinding = {
27
+ /**
28
+ * - 1-based line, omitted when it cannot be
29
+ * established honestly.
30
+ */
31
+ line?: number | undefined;
32
+ /**
33
+ * - 1-based column, omitted likewise.
34
+ */
35
+ column?: number | undefined;
36
+ /**
37
+ * - How the finding should be treated.
38
+ */
39
+ severity: "error" | "warning";
40
+ /**
41
+ * - What is wrong, in one sentence.
42
+ */
43
+ message: string;
44
+ };
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Which artifact a template file builds.
3
+ *
4
+ * Inferred from the template's own name so the usual case takes no
5
+ * configuration: a repository that ships `system.template.json` is a system,
6
+ * and one that ships `module.template.json` is a module. That is the same pair
7
+ * `@heroiclands/content-build` resolves a package manifest from, so the two
8
+ * cannot disagree about what a repository is.
9
+ *
10
+ * @param {string} templatePath - Path to the manifest template.
11
+ * @returns {"system"|"module"} The artifact name.
12
+ * @throws {TypeError} When the name identifies neither kind — a template called
13
+ * something else leaves nothing to infer from, and guessing would silently
14
+ * emit a manifest Foundry never looks for.
15
+ */
16
+ export function artifactFromTemplate(templatePath: string): "system" | "module";
17
+ /**
18
+ * The repository's web address, from whatever spelling `package.json` carries.
19
+ *
20
+ * npm accepts several: a plain `https://` URL, the `git+https://…​.git` form npm
21
+ * itself writes, and a trailing slash either way. Foundry fetches
22
+ * `<url>/releases/latest/download/<artifact>.json` literally, so a `git+` prefix
23
+ * or a `.git` suffix left in place yields a 404 on every update check — with no
24
+ * error anywhere, because nothing fetches that URL until a user's Foundry does.
25
+ * `sohl-kethira-basic` declares the `git+…​.git` form today.
26
+ *
27
+ * @param {string|{url?: string}} repository - `package.json`'s `repository`
28
+ * field, in either object or shorthand-string form.
29
+ * @returns {string} The normalised `https://` URL, with no trailing slash.
30
+ * @throws {TypeError} When no URL can be read. A manifest with no addresses is
31
+ * worse than a missing one: Foundry installs it and never offers an update.
32
+ */
33
+ export function normalizeRepoUrl(repository: string | {
34
+ url?: string;
35
+ }): string;
36
+ /**
37
+ * The four addresses a Foundry manifest advertises.
38
+ *
39
+ * `manifest` deliberately points at **`releases/latest`** rather than at this
40
+ * version: it is the URL an *installed* package re-fetches to discover that a
41
+ * newer one exists, so pinning it to the version being built would freeze every
42
+ * install at that release forever. `download` points at this exact version,
43
+ * because that is the archive this manifest describes.
44
+ *
45
+ * @param {object} opts
46
+ * @param {string} opts.repoUrl - Normalised repository URL.
47
+ * @param {string} opts.version - The version being built.
48
+ * @param {"system"|"module"} opts.artifact - Which artifact is shipped.
49
+ * @returns {{url: string, bugs: string, manifest: string, download: string}}
50
+ */
51
+ export function releaseUrls({ repoUrl, version, artifact }: {
52
+ repoUrl: string;
53
+ version: string;
54
+ artifact: "system" | "module";
55
+ }): {
56
+ url: string;
57
+ bugs: string;
58
+ manifest: string;
59
+ download: string;
60
+ };
61
+ /**
62
+ * Stamp a manifest template with the facts that must not be transcribed.
63
+ *
64
+ * Pure: the template is not mutated, and the result is a new object.
65
+ *
66
+ * `flags` is merged **per namespace**, not wholesale, so a template may carry
67
+ * its own keys under the same namespace and keep them. A caller supplies
68
+ * whatever its package needs there — the credits journal's UUID, the settings
69
+ * sidebar's links — because those are facts about one package, not about being
70
+ * a Foundry package.
71
+ *
72
+ * @param {object} template - The parsed manifest template.
73
+ * @param {object} opts
74
+ * @param {string} opts.version - The version being built.
75
+ * @param {string} opts.repoUrl - Normalised repository URL.
76
+ * @param {"system"|"module"} opts.artifact - Which artifact is shipped.
77
+ * @param {Record<string, object>} [opts.flags] - Namespaced flags to merge.
78
+ * @returns {object} The stamped manifest.
79
+ */
80
+ export function stampManifest(template: object, { version, repoUrl, artifact, flags }: {
81
+ version: string;
82
+ repoUrl: string;
83
+ artifact: "system" | "module";
84
+ flags?: Record<string, object> | undefined;
85
+ }): object;
86
+ /**
87
+ * Read a manifest template, stamp it, and write the result into the stage.
88
+ *
89
+ * The only export here that touches disk. Everything it decides is decided by
90
+ * the pure functions above, so the rules stay testable without a filesystem.
91
+ *
92
+ * @param {object} opts
93
+ * @param {string} opts.templatePath - The manifest template to read.
94
+ * @param {object} opts.packageJson - The parsed `package.json`, which owns the
95
+ * version and the repository address.
96
+ * @param {string} opts.outDir - Directory to write the manifest into, created
97
+ * if absent.
98
+ * @param {"system"|"module"} [opts.artifact] - Overrides the artifact inferred
99
+ * from the template's name.
100
+ * @param {Record<string, object>} [opts.flags] - Namespaced flags to merge.
101
+ * @returns {Promise<{path: string, manifest: object}>} Where it was written,
102
+ * and what was written.
103
+ */
104
+ export function writeFoundryManifest({ templatePath, packageJson, outDir, artifact, flags, }: {
105
+ templatePath: string;
106
+ packageJson: object;
107
+ outDir: string;
108
+ artifact?: "module" | "system" | undefined;
109
+ flags?: Record<string, object> | undefined;
110
+ }): Promise<{
111
+ path: string;
112
+ manifest: object;
113
+ }>;
114
+ /**
115
+ * The two package kinds Foundry defines, as the artifact name each one's
116
+ * manifest and release archive are called.
117
+ *
118
+ * A system ships `system.json` / `system.zip`; a module ships `module.json` /
119
+ * `module.zip`. Foundry fetches those exact names, so the pair is not a naming
120
+ * convention this project is free to choose.
121
+ */
122
+ export const ARTIFACTS: readonly string[];
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Zip the staged tree and place the manifest beside the archive.
3
+ *
4
+ * **Waits for the output stream to close, not merely for the archive to
5
+ * finalize.** `finalize()` resolves once archiver has finished *appending*
6
+ * entries, which is before the bytes have necessarily reached disk; returning
7
+ * there can hand a later step — an upload, a checksum — a truncated file. The
8
+ * failure is timing-dependent, so it survives every run that happens to be
9
+ * fast enough, which is what makes it worth being explicit about.
10
+ *
11
+ * @param {object} [opts]
12
+ * @param {string} [opts.stageDir] - The staged package tree.
13
+ * @param {string} [opts.outDir] - Where the release assets are written.
14
+ * @param {"system"|"module"} [opts.artifact] - Which artifact is shipped.
15
+ * Determines both asset names.
16
+ * @returns {Promise<{zip: string, manifest: string, bytes: number,
17
+ * version: string}>} The two paths written, the archive's size, and the
18
+ * version the manifest declares.
19
+ * @throws {Error} When the stage has no manifest — there is nothing to release,
20
+ * and an archive without one installs as nothing.
21
+ */
22
+ export function packRelease({ stageDir, outDir, artifact, }?: {
23
+ stageDir?: string | undefined;
24
+ outDir?: string | undefined;
25
+ artifact?: "module" | "system" | undefined;
26
+ }): Promise<{
27
+ zip: string;
28
+ manifest: string;
29
+ bytes: number;
30
+ version: string;
31
+ }>;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * A source that does not exist, described for a human.
3
+ *
4
+ * Pure, and separate from the copying, so the whole list is reported at once.
5
+ * Discovering missing sources one exception at a time means one fix, one
6
+ * rebuild, one more exception.
7
+ *
8
+ * @param {ReadonlyArray<readonly [string, string]>} entries - `[source, dest]`
9
+ * pairs, sources relative to `cwd` or absolute.
10
+ * @param {string} [cwd] - Resolved against this. Defaults to the process cwd.
11
+ * @returns {string[]} Every source that is absent, in the order listed.
12
+ */
13
+ export function missingSources(entries: ReadonlyArray<readonly [string, string]>, cwd?: string): string[];
14
+ /**
15
+ * Recursively copy `src` to `dest`.
16
+ *
17
+ * `transform(sourcePath)` may return a string to write **instead of** a byte
18
+ * copy; returning `null` or `undefined` falls back to copying the bytes. That
19
+ * is what lets a repository theme its icons, rewrite a config, or stamp a file
20
+ * as it stages it, without this function knowing anything about why.
21
+ *
22
+ * @param {string} src - Source file or directory.
23
+ * @param {string} dest - Destination path.
24
+ * @param {object} [opts]
25
+ * @param {(sourcePath: string) => string|null|undefined} [opts.transform] -
26
+ * Per-file transform.
27
+ * @returns {number} How many files were written.
28
+ */
29
+ export function copyTree(src: string, dest: string, { transform }?: {
30
+ transform?: ((sourcePath: string) => string | null | undefined) | undefined;
31
+ }): number;
32
+ /**
33
+ * Copy every listed source into the stage, refusing to start if any is absent.
34
+ *
35
+ * **The guard is the point.** A listed path that does not exist is an error,
36
+ * not a silent skip: a missing `lang/` ships a package with no localization and
37
+ * nothing said so, and a missing `templates/` ships one whose every sheet fails
38
+ * to render. Both are indistinguishable from a successful build in the log.
39
+ *
40
+ * The check runs over the whole list *before* anything is copied, so a bad list
41
+ * leaves no half-populated stage behind.
42
+ *
43
+ * @param {ReadonlyArray<readonly [string, string]>} entries - `[source, dest]`
44
+ * pairs.
45
+ * @param {object} [opts]
46
+ * @param {string} [opts.cwd] - Sources and destinations resolve against this.
47
+ * @param {(sourcePath: string) => string|null|undefined} [opts.transform] -
48
+ * Per-file transform, applied to every entry.
49
+ * @returns {{entries: number, files: number}} What was staged.
50
+ * @throws {Error} When any source is missing, naming all of them.
51
+ */
52
+ export function stageAssets(entries: ReadonlyArray<readonly [string, string]>, { cwd, transform }?: {
53
+ cwd?: string | undefined;
54
+ transform?: ((sourcePath: string) => string | null | undefined) | undefined;
55
+ }): {
56
+ entries: number;
57
+ files: number;
58
+ };
59
+ /**
60
+ * Remove the build artefacts a repository regenerates.
61
+ *
62
+ * A directory that is already gone is not an error — the command has to be safe
63
+ * to run repeatedly, and "clean when already clean" is the ordinary case.
64
+ *
65
+ * @param {string} root - Repository root; every directory resolves against it.
66
+ * @param {object} [opts]
67
+ * @param {readonly string[]} [opts.extra] - Directories beyond
68
+ * {@link BUILD_ARTIFACT_DIRS} that this repository also regenerates.
69
+ * @param {boolean} [opts.includeNodeModules] - Also remove `node_modules`, the
70
+ * `distclean` case.
71
+ * @returns {string[]} The directories removed, as listed.
72
+ */
73
+ export function cleanBuildArtifacts(root: string, { extra, includeNodeModules }?: {
74
+ extra?: readonly string[] | undefined;
75
+ includeNodeModules?: boolean | undefined;
76
+ }): string[];
77
+ /**
78
+ * Directories every HeroicLands repository regenerates and none commits.
79
+ *
80
+ * A repository adds its own — `sohl-thalorna` also clears the Hugo output
81
+ * beneath `site/` — but these four are common to all of them because they come
82
+ * from the shared toolchain rather than from any one package's layout.
83
+ */
84
+ export const BUILD_ARTIFACT_DIRS: readonly string[];
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Locating a literal inside an arbitrary text file.
3
+ *
4
+ * A build check reports a **finding**, and a finding is only actionable if it
5
+ * says where it is (#1668). Most findings are *about* a string the check
6
+ * matched — a key, a marker, a caption — so its position is one string search
7
+ * away, and making that search is the difference between a finding that can be
8
+ * opened and one that has to be hunted for.
9
+ *
10
+ * `@heroiclands/content-build` owns the diagnostic **format**, and its
11
+ * `positionInBody` maps an offset within a parsed content note back to its
12
+ * file. That is a different job: the checks here read localization files,
13
+ * manifests, source and bundles — none of which are notes. So this module
14
+ * carries the generic operation, and nothing carries it twice.
15
+ *
16
+ * Plain ESM with no filesystem access, so it is unit-testable.
17
+ *
18
+ * @module
19
+ */
20
+ /**
21
+ * Where a literal sits in a text.
22
+ *
23
+ * @param {string} text - The file's contents.
24
+ * @param {string} needle - The literal to locate.
25
+ * @param {number} [occurrence] - Which occurrence, 1-based. Repeats of the same
26
+ * literal are otherwise indistinguishable, which is the symptom the
27
+ * diagnostic format exists to remove.
28
+ * @returns {{line: number, column: number}|undefined} 1-based position, or
29
+ * `undefined` when the literal is not there. A caller that gets `undefined`
30
+ * reports the file alone rather than a position that is not the problem.
31
+ */
32
+ export function locateInText(text: string, needle: string, occurrence?: number): {
33
+ line: number;
34
+ column: number;
35
+ } | undefined;
36
+ /**
37
+ * Where a literal sits, as spreadable diagnostic fields.
38
+ *
39
+ * Keeps the drop-rather-than-guess rule in one place: an unfound literal
40
+ * contributes no position at all, rather than `undefined` fields that read as a
41
+ * bug or a `1:1` that sends the reader to the top of the file.
42
+ *
43
+ * @param {string} text - The file's contents.
44
+ * @param {string} needle - The literal to locate.
45
+ * @param {number} [occurrence] - Which occurrence, 1-based.
46
+ * @returns {{line?: number, column?: number}} Spreadable position fields.
47
+ */
48
+ export function positionOf(text: string, needle: string, occurrence?: number): {
49
+ line?: number;
50
+ column?: number;
51
+ };