@markii/pack 0.7.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sadigaxund
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,5 @@
1
+ # @markii/pack
2
+
3
+ Component pack contract for [Markii](https://github.com/sadigaxund/markii): the `pack.json` manifest shape (`docs/packs.md`), namespace and engine rules, and hand-rolled validation. No React, no markdown parsing, no bundle loading — this package defines the contract that a later registry-loading slice builds against.
4
+
5
+ See the [repository](https://github.com/sadigaxund/markii) for the format spec and the reference library as a whole.
@@ -0,0 +1,6 @@
1
+ export type { PackManifest, PackManifestParseResult } from './manifest.js';
2
+ export { parsePackManifest } from './manifest.js';
3
+ export type { ComposeDirectiveNameResult, NamespaceCollision, NamespaceValidationResult, } from './namespace.js';
4
+ export { RESERVED_NAMESPACE_SEGMENTS, composeDirectiveName, detectNamespaceCollisions, validateLocalComponentName, validatePackName, } from './namespace.js';
5
+ export type { UsesResolution } from './uses.js';
6
+ export { isValidPackNameShape, resolveUses } from './uses.js';
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ // @markii/pack: the neutral component-pack contract (docs/packs.md). This
2
+ // is slice 0 of issue #3 — the manifest shape, namespace/engine rules, and
3
+ // hand-rolled validation only. No registry loading, no `uses:` surfacing,
4
+ // no sandboxed `require`, no filesystem reads: those are later slices.
5
+ export { parsePackManifest } from './manifest.js';
6
+ export { RESERVED_NAMESPACE_SEGMENTS, composeDirectiveName, detectNamespaceCollisions, validateLocalComponentName, validatePackName, } from './namespace.js';
7
+ export { isValidPackNameShape, resolveUses } from './uses.js';
@@ -0,0 +1,50 @@
1
+ /**
2
+ * `pack.json`'s contract (docs/packs.md). Three fields, all required:
3
+ *
4
+ * { "name": "ana", "engine": "react",
5
+ * "components": { "timeline": "./Timeline.tsx" } }
6
+ *
7
+ * `name` is the pack's namespace (see `./namespace.ts`). `engine` names the
8
+ * renderer framework the pack's components are written for; it is any
9
+ * string — the host decides whether it can run it, so this package does
10
+ * not maintain a closed enum of known engines. `components` maps a local
11
+ * component name to a pack-relative source path.
12
+ *
13
+ * Shared Lua modules are NOT a manifest field. docs/packs.md says they
14
+ * travel under a conventional `scripts/` directory inside the pack
15
+ * (`require "ana/http"`), the same way a bundle's `scripts/` works — there
16
+ * is no separate manifest declaration for them, so this type doesn't add
17
+ * one. (Flagged in the slice-0 report for orchestrator sign-off in case
18
+ * docs/packs.md meant something more explicit.)
19
+ */
20
+ export interface PackManifest {
21
+ /** The pack's namespace, e.g. `"ana"`. See `validatePackName`. */
22
+ name: string;
23
+ /** The renderer framework the pack's components target, e.g. `"react"`. */
24
+ engine: string;
25
+ /** Local component name -> pack-relative source path. */
26
+ components: Record<string, string>;
27
+ }
28
+ export type PackManifestParseResult = {
29
+ ok: true;
30
+ manifest: PackManifest;
31
+ warnings: string[];
32
+ } | {
33
+ ok: false;
34
+ errors: string[];
35
+ };
36
+ /**
37
+ * Hand-rolled `pack.json` validation (no schema library — see AGENTS.md
38
+ * dependency policy), mirroring `@markii/bundle`'s `parseManifest` style:
39
+ * never throws, malformed JSON or a non-object root comes back as
40
+ * `{ ok: false, errors }`, and unrecognized top-level keys are
41
+ * forward-compatible warnings rather than errors.
42
+ *
43
+ * Reads the `components` map with `Object.hasOwn` only — never a `for...in`
44
+ * or bare property access — so a hostile manifest cannot use inherited
45
+ * properties (`{}.toString`, etc.) to inject a component entry, and a key
46
+ * literally named `__proto__`/`constructor`/`prototype` is validated (and
47
+ * rejected — see `validateLocalComponentName`'s charset) like any other
48
+ * string key, never treated as a prototype-chain write.
49
+ */
50
+ export declare function parsePackManifest(json: string): PackManifestParseResult;
@@ -0,0 +1,123 @@
1
+ import { validateLocalComponentName, validatePackName } from './namespace.js';
2
+ const KNOWN_TOP_LEVEL_KEYS = new Set(['name', 'engine', 'components']);
3
+ function isPlainObject(value) {
4
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
5
+ }
6
+ /**
7
+ * Hand-rolled `pack.json` validation (no schema library — see AGENTS.md
8
+ * dependency policy), mirroring `@markii/bundle`'s `parseManifest` style:
9
+ * never throws, malformed JSON or a non-object root comes back as
10
+ * `{ ok: false, errors }`, and unrecognized top-level keys are
11
+ * forward-compatible warnings rather than errors.
12
+ *
13
+ * Reads the `components` map with `Object.hasOwn` only — never a `for...in`
14
+ * or bare property access — so a hostile manifest cannot use inherited
15
+ * properties (`{}.toString`, etc.) to inject a component entry, and a key
16
+ * literally named `__proto__`/`constructor`/`prototype` is validated (and
17
+ * rejected — see `validateLocalComponentName`'s charset) like any other
18
+ * string key, never treated as a prototype-chain write.
19
+ */
20
+ export function parsePackManifest(json) {
21
+ let raw;
22
+ try {
23
+ raw = JSON.parse(json);
24
+ }
25
+ catch (err) {
26
+ return {
27
+ ok: false,
28
+ errors: [
29
+ `malformed JSON: ${err instanceof Error ? err.message : String(err)}`,
30
+ ],
31
+ };
32
+ }
33
+ if (!isPlainObject(raw)) {
34
+ return { ok: false, errors: ['pack manifest must be a JSON object'] };
35
+ }
36
+ const obj = raw;
37
+ const errors = [];
38
+ const warnings = [];
39
+ // --- name (required) ---
40
+ let name;
41
+ if (!Object.hasOwn(obj, 'name')) {
42
+ errors.push('"name" is required');
43
+ }
44
+ else {
45
+ const nameResult = validatePackName(obj.name);
46
+ if (!nameResult.ok) {
47
+ errors.push(`"name": ${nameResult.reason}`);
48
+ }
49
+ else {
50
+ name = obj.name;
51
+ }
52
+ }
53
+ // --- engine (required) ---
54
+ let engine;
55
+ if (!Object.hasOwn(obj, 'engine')) {
56
+ errors.push('"engine" is required');
57
+ }
58
+ else if (typeof obj.engine !== 'string' || obj.engine.length === 0) {
59
+ errors.push('"engine" must be a non-empty string');
60
+ }
61
+ else {
62
+ engine = obj.engine;
63
+ }
64
+ // --- components (required) ---
65
+ let components;
66
+ if (!Object.hasOwn(obj, 'components')) {
67
+ errors.push('"components" is required');
68
+ }
69
+ else if (!isPlainObject(obj.components)) {
70
+ errors.push('"components" must be an object');
71
+ }
72
+ else {
73
+ const componentsObj = obj.components;
74
+ const result = {};
75
+ let componentsValid = true;
76
+ for (const key of Object.keys(componentsObj)) {
77
+ if (!Object.hasOwn(componentsObj, key))
78
+ continue;
79
+ const localNameResult = validateLocalComponentName(key);
80
+ if (!localNameResult.ok) {
81
+ errors.push(`"components" key "${key}": ${localNameResult.reason}`);
82
+ componentsValid = false;
83
+ continue;
84
+ }
85
+ const value = componentsObj[key];
86
+ if (typeof value !== 'string' || value.length === 0) {
87
+ errors.push(`"components.${key}" must be a non-empty string (a pack-relative source path)`);
88
+ componentsValid = false;
89
+ continue;
90
+ }
91
+ // Only the type is checked here, matching @markii/bundle's
92
+ // `document` field: path-jailing a pack-relative source path is a
93
+ // filesystem concern for the later registry-loading slice, not this
94
+ // contract-only one. Object.hasOwn above already keeps prototype
95
+ // members out of `result`.
96
+ result[key] = value;
97
+ }
98
+ if (Object.keys(componentsObj).length === 0) {
99
+ errors.push('"components" must have at least one entry');
100
+ componentsValid = false;
101
+ }
102
+ if (componentsValid) {
103
+ components = result;
104
+ }
105
+ }
106
+ // --- unknown top-level keys: forward-compat warning, not an error ---
107
+ for (const key of Object.keys(obj)) {
108
+ if (Object.hasOwn(obj, key) && !KNOWN_TOP_LEVEL_KEYS.has(key)) {
109
+ warnings.push(`unknown pack manifest key "${key}" (ignored by this implementation)`);
110
+ }
111
+ }
112
+ if (errors.length > 0 ||
113
+ name === undefined ||
114
+ engine === undefined ||
115
+ components === undefined) {
116
+ return { ok: false, errors };
117
+ }
118
+ return {
119
+ ok: true,
120
+ manifest: { name, engine, components },
121
+ warnings,
122
+ };
123
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Namespace rules from docs/packs.md and spec.md §1:
3
+ *
4
+ * - Directive names MUST NOT contain `:` (reserved syntax).
5
+ * - A namespaced directive name joins the pack's namespace and the
6
+ * component's local name with `-` or `_`.
7
+ * - The bundle's reserved first-path-segment names (`scripts`, `assets`,
8
+ * `.cache`) can never be a pack namespace, so a pack can never shadow
9
+ * bundle structure in `require` or in directive names.
10
+ *
11
+ * This module is pure and dependency-free, mirroring `@markii/bundle`'s
12
+ * `paths.ts`: no filesystem, no registry, just the string rules a later
13
+ * slice (registry loading, install-time rejection) builds on.
14
+ *
15
+ * Design decision (orchestrator sign-off requested — see the slice-0
16
+ * report): the charset chosen for both a pack namespace and a pack's local
17
+ * component names is lowercase-kebab, matching spec.md §1's "SHOULD be
18
+ * lowercase-kebab" guidance for directive names generally: a leading
19
+ * lowercase letter, then lowercase letters/digits, with single internal
20
+ * hyphens as separators (no leading/trailing hyphen, no doubled hyphen).
21
+ * This is deliberately conservative — it's stricter than the spec's bare
22
+ * "MUST NOT contain `:`" requirement — because a namespace also has to
23
+ * compose cleanly with a local name using `-` or `_` without producing an
24
+ * ambiguous or surprising directive name. Underscore is accepted as the
25
+ * separator when composing (`ana_timeline`) but is not accepted as a
26
+ * character inside the namespace or local-name segments themselves, so the
27
+ * composed name's own separator choice stays unambiguous.
28
+ */
29
+ /** The reserved bundle directory names a pack namespace must never collide with (spec.md §1, §8). */
30
+ export declare const RESERVED_NAMESPACE_SEGMENTS: ReadonlySet<string>;
31
+ export type NamespaceValidationResult = {
32
+ ok: true;
33
+ } | {
34
+ ok: false;
35
+ reason: string;
36
+ };
37
+ /**
38
+ * Validates a pack name (the pack's namespace). Never throws.
39
+ *
40
+ * Rejects: non-strings, empty strings, anything outside the lowercase-kebab
41
+ * charset (this also rejects `:` and any prototype-pollution-shaped name
42
+ * such as `__proto__` or `constructor`, since neither matches the charset),
43
+ * and the reserved bundle segments `scripts`, `assets`, `.cache`.
44
+ */
45
+ export declare function validatePackName(name: unknown): NamespaceValidationResult;
46
+ /**
47
+ * Validates a pack's local component name (the key on the manifest's
48
+ * `components` map, before namespacing). Same lowercase-kebab charset as a
49
+ * pack name; this also rejects prototype-pollution-shaped keys.
50
+ */
51
+ export declare function validateLocalComponentName(name: unknown): NamespaceValidationResult;
52
+ export type ComposeDirectiveNameResult = {
53
+ ok: true;
54
+ name: string;
55
+ } | {
56
+ ok: false;
57
+ reason: string;
58
+ };
59
+ /**
60
+ * Composes the directive name an author types to reference a pack's
61
+ * component, e.g. `composeDirectiveName('ana', 'timeline')` -> `ana-timeline`
62
+ * (docs/packs.md). Validates both inputs first, then the composed result
63
+ * (defense in depth: the composed string must still contain no `:` and must
64
+ * still be a legal directive-name shape).
65
+ *
66
+ * `separator` defaults to `-`; `_` is also accepted per spec.md §1's "joins
67
+ * the namespace and name with `-` or `_`".
68
+ */
69
+ export declare function composeDirectiveName(packName: string, localName: string, separator?: '-' | '_'): ComposeDirectiveNameResult;
70
+ export interface NamespaceCollision {
71
+ namespace: string;
72
+ /** How many times this namespace appears in the input. */
73
+ count: number;
74
+ }
75
+ /**
76
+ * Given several installed pack namespaces, returns the ones that appear
77
+ * more than once. Pure predicate only — docs/packs.md says installing two
78
+ * packs with the same namespace is rejected at install time, but that
79
+ * install-time rejection (and the vault-library-shadows-a-pack warning
80
+ * case) is a later slice; this is the detection primitive it will use.
81
+ *
82
+ * Comparison is exact-string (case-sensitive), matching `validatePackName`
83
+ * which already forces lowercase, so two namespaces differing only in case
84
+ * cannot arise from valid input in the first place.
85
+ */
86
+ export declare function detectNamespaceCollisions(namespaces: readonly string[]): NamespaceCollision[];
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Namespace rules from docs/packs.md and spec.md §1:
3
+ *
4
+ * - Directive names MUST NOT contain `:` (reserved syntax).
5
+ * - A namespaced directive name joins the pack's namespace and the
6
+ * component's local name with `-` or `_`.
7
+ * - The bundle's reserved first-path-segment names (`scripts`, `assets`,
8
+ * `.cache`) can never be a pack namespace, so a pack can never shadow
9
+ * bundle structure in `require` or in directive names.
10
+ *
11
+ * This module is pure and dependency-free, mirroring `@markii/bundle`'s
12
+ * `paths.ts`: no filesystem, no registry, just the string rules a later
13
+ * slice (registry loading, install-time rejection) builds on.
14
+ *
15
+ * Design decision (orchestrator sign-off requested — see the slice-0
16
+ * report): the charset chosen for both a pack namespace and a pack's local
17
+ * component names is lowercase-kebab, matching spec.md §1's "SHOULD be
18
+ * lowercase-kebab" guidance for directive names generally: a leading
19
+ * lowercase letter, then lowercase letters/digits, with single internal
20
+ * hyphens as separators (no leading/trailing hyphen, no doubled hyphen).
21
+ * This is deliberately conservative — it's stricter than the spec's bare
22
+ * "MUST NOT contain `:`" requirement — because a namespace also has to
23
+ * compose cleanly with a local name using `-` or `_` without producing an
24
+ * ambiguous or surprising directive name. Underscore is accepted as the
25
+ * separator when composing (`ana_timeline`) but is not accepted as a
26
+ * character inside the namespace or local-name segments themselves, so the
27
+ * composed name's own separator choice stays unambiguous.
28
+ */
29
+ /** The reserved bundle directory names a pack namespace must never collide with (spec.md §1, §8). */
30
+ export const RESERVED_NAMESPACE_SEGMENTS = new Set([
31
+ 'scripts',
32
+ 'assets',
33
+ '.cache',
34
+ ]);
35
+ // Lowercase-kebab: starts with a lowercase letter, then lowercase
36
+ // letters/digits, with single internal hyphens. No leading/trailing
37
+ // hyphen, no consecutive hyphens, no underscore, no colon, no uppercase.
38
+ const SEGMENT_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
39
+ // Plain object prototype members that would otherwise pass SEGMENT_RE (they
40
+ // are lowercase-kebab-shaped, no ":" or "_"). Rejected by name, defense in
41
+ // depth alongside every reader in this package using Object.hasOwn: a
42
+ // namespace or local name equal to one of these is never a meaningful
43
+ // component name and is exactly the shape a prototype-pollution probe uses.
44
+ const PROTOTYPE_MEMBER_NAMES = new Set([
45
+ '__proto__',
46
+ 'constructor',
47
+ 'prototype',
48
+ 'hasownproperty',
49
+ 'valueof',
50
+ 'tostring',
51
+ 'isprototypeof',
52
+ ]);
53
+ /**
54
+ * Validates a pack name (the pack's namespace). Never throws.
55
+ *
56
+ * Rejects: non-strings, empty strings, anything outside the lowercase-kebab
57
+ * charset (this also rejects `:` and any prototype-pollution-shaped name
58
+ * such as `__proto__` or `constructor`, since neither matches the charset),
59
+ * and the reserved bundle segments `scripts`, `assets`, `.cache`.
60
+ */
61
+ export function validatePackName(name) {
62
+ if (typeof name !== 'string' || name.length === 0) {
63
+ return { ok: false, reason: 'pack name must be a non-empty string' };
64
+ }
65
+ if (name.includes(':')) {
66
+ return { ok: false, reason: 'pack name must not contain ":"' };
67
+ }
68
+ if (RESERVED_NAMESPACE_SEGMENTS.has(name)) {
69
+ return {
70
+ ok: false,
71
+ reason: `"${name}" is a reserved bundle segment and cannot be a pack namespace`,
72
+ };
73
+ }
74
+ if (PROTOTYPE_MEMBER_NAMES.has(name.toLowerCase())) {
75
+ return {
76
+ ok: false,
77
+ reason: `"${name}" is a reserved object-prototype member name and cannot be a pack name`,
78
+ };
79
+ }
80
+ if (!SEGMENT_RE.test(name)) {
81
+ return {
82
+ ok: false,
83
+ reason: 'pack name must be lowercase-kebab: a lowercase letter, then lowercase letters/digits, with single internal hyphens',
84
+ };
85
+ }
86
+ return { ok: true };
87
+ }
88
+ /**
89
+ * Validates a pack's local component name (the key on the manifest's
90
+ * `components` map, before namespacing). Same lowercase-kebab charset as a
91
+ * pack name; this also rejects prototype-pollution-shaped keys.
92
+ */
93
+ export function validateLocalComponentName(name) {
94
+ if (typeof name !== 'string' || name.length === 0) {
95
+ return {
96
+ ok: false,
97
+ reason: 'component name must be a non-empty string',
98
+ };
99
+ }
100
+ if (name.includes(':')) {
101
+ return { ok: false, reason: 'component name must not contain ":"' };
102
+ }
103
+ if (PROTOTYPE_MEMBER_NAMES.has(name.toLowerCase())) {
104
+ return {
105
+ ok: false,
106
+ reason: `"${name}" is a reserved object-prototype member name and cannot be a component name`,
107
+ };
108
+ }
109
+ if (!SEGMENT_RE.test(name)) {
110
+ return {
111
+ ok: false,
112
+ reason: 'component name must be lowercase-kebab: a lowercase letter, then lowercase letters/digits, with single internal hyphens',
113
+ };
114
+ }
115
+ return { ok: true };
116
+ }
117
+ /**
118
+ * Composes the directive name an author types to reference a pack's
119
+ * component, e.g. `composeDirectiveName('ana', 'timeline')` -> `ana-timeline`
120
+ * (docs/packs.md). Validates both inputs first, then the composed result
121
+ * (defense in depth: the composed string must still contain no `:` and must
122
+ * still be a legal directive-name shape).
123
+ *
124
+ * `separator` defaults to `-`; `_` is also accepted per spec.md §1's "joins
125
+ * the namespace and name with `-` or `_`".
126
+ */
127
+ export function composeDirectiveName(packName, localName, separator = '-') {
128
+ const packResult = validatePackName(packName);
129
+ if (!packResult.ok)
130
+ return packResult;
131
+ const localResult = validateLocalComponentName(localName);
132
+ if (!localResult.ok)
133
+ return localResult;
134
+ const composed = `${packName}${separator}${localName}`;
135
+ if (composed.includes(':')) {
136
+ return {
137
+ ok: false,
138
+ reason: 'composed directive name must not contain ":"',
139
+ };
140
+ }
141
+ // The composed name must still look like a directive name: lowercase
142
+ // letters/digits/hyphens/underscores only, starting with a letter. This
143
+ // is deliberately looser than SEGMENT_RE (it allows the separator
144
+ // character throughout) since the composed string is not itself
145
+ // re-parsed into namespace/local parts anywhere in this package.
146
+ if (!/^[a-z][a-z0-9_-]*$/.test(composed)) {
147
+ return {
148
+ ok: false,
149
+ reason: `composed directive name "${composed}" is not a legal directive name`,
150
+ };
151
+ }
152
+ return { ok: true, name: composed };
153
+ }
154
+ /**
155
+ * Given several installed pack namespaces, returns the ones that appear
156
+ * more than once. Pure predicate only — docs/packs.md says installing two
157
+ * packs with the same namespace is rejected at install time, but that
158
+ * install-time rejection (and the vault-library-shadows-a-pack warning
159
+ * case) is a later slice; this is the detection primitive it will use.
160
+ *
161
+ * Comparison is exact-string (case-sensitive), matching `validatePackName`
162
+ * which already forces lowercase, so two namespaces differing only in case
163
+ * cannot arise from valid input in the first place.
164
+ */
165
+ export function detectNamespaceCollisions(namespaces) {
166
+ const counts = new Map();
167
+ for (const ns of namespaces) {
168
+ counts.set(ns, (counts.get(ns) ?? 0) + 1);
169
+ }
170
+ const collisions = [];
171
+ for (const [namespace, count] of counts) {
172
+ if (count > 1)
173
+ collisions.push({ namespace, count });
174
+ }
175
+ return collisions;
176
+ }
package/dist/uses.d.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Resolving a note's `uses:` declaration (docs/packs.md, @markii/core's
3
+ * `Frontmatter.uses`) against the packs a host actually has installed.
4
+ *
5
+ * This is host-facing metadata only: it lets a renderer say "this note uses
6
+ * pack `ana`, which is not installed" instead of showing an unexplained
7
+ * fallback box. It does not load, register, or validate components — it
8
+ * just answers "of the packs this note declared, which ones do I have?".
9
+ *
10
+ * Pure and dependency-free, mirroring the rest of this package: no
11
+ * filesystem, no registry, no host coupling.
12
+ */
13
+ /**
14
+ * The result of resolving a note's declared `uses:` list against a host's
15
+ * installed pack namespaces.
16
+ *
17
+ * `declared` is `false` only when the note had no `uses:` at all
18
+ * (`Frontmatter.uses` was `undefined` — "not declared", as opposed to
19
+ * `uses: []`, "declared, and it names no packs"). When `declared` is
20
+ * `false`, `missing` and `satisfied` are both empty: there is nothing to
21
+ * report either way.
22
+ *
23
+ * `missing` and `satisfied` partition the de-duplicated declared list
24
+ * (order-preserving, first occurrence wins) between namespaces the host
25
+ * does not have installed and ones it does. A declared name that is not
26
+ * even a syntactically valid pack namespace (see `validatePackName`) can
27
+ * never be "installed" under any host, so it is counted as `missing` too —
28
+ * there is no separate bucket for it. The distinction a host UI might want
29
+ * to draw ("pack not installed" vs. "that name could never be a pack") is
30
+ * not this helper's job; a host that wants it can run `validatePackName`
31
+ * itself over `missing`.
32
+ */
33
+ export interface UsesResolution {
34
+ declared: boolean;
35
+ missing: string[];
36
+ satisfied: string[];
37
+ }
38
+ /**
39
+ * Resolves a note's declared `uses:` list against the pack namespaces a
40
+ * host has installed. Never throws.
41
+ *
42
+ * - `declaredUses` is `undefined` when the note declared no `uses:` at all;
43
+ * `resolveUses` returns `{ declared: false, missing: [], satisfied: [] }`
44
+ * in that case without inspecting `installedNamespaces`.
45
+ * - `declaredUses: []` ("declared, names no packs") returns
46
+ * `{ declared: true, missing: [], satisfied: [] }`.
47
+ * - `installedNamespaces` is consumed into a `Set` once, so any iterable
48
+ * works (an array, another `Set`, a generator) and lookups are O(1)
49
+ * regardless of how many packs are installed. A `Set` built from
50
+ * attacker-controlled strings (e.g. `"__proto__"`) is a safe lookup
51
+ * target: `Set.prototype.has` does not consult the prototype chain the
52
+ * way a plain-object property read would, so no `Object.hasOwn` guard is
53
+ * needed here (unlike `namespace.ts`'s object-key readers).
54
+ * - Duplicate names in `declaredUses` are de-duplicated; the first
55
+ * occurrence's position is what determines its place in the output
56
+ * arrays, so host messaging ("missing: ana, gh") stays stable regardless
57
+ * of how many times a note repeats a name.
58
+ * - Neither input is mutated.
59
+ */
60
+ export declare function resolveUses(declaredUses: readonly string[] | undefined, installedNamespaces: Iterable<string>): UsesResolution;
61
+ /**
62
+ * Convenience re-export point for callers that want to flag a declared name
63
+ * that could never be a valid pack namespace in the first place (distinct
64
+ * from "valid namespace, just not installed"). `resolveUses` itself does not
65
+ * draw this distinction — see `UsesResolution`'s docs — so a host that wants
66
+ * it filters `missing` through this predicate.
67
+ */
68
+ export declare function isValidPackNameShape(name: string): boolean;
package/dist/uses.js ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Resolving a note's `uses:` declaration (docs/packs.md, @markii/core's
3
+ * `Frontmatter.uses`) against the packs a host actually has installed.
4
+ *
5
+ * This is host-facing metadata only: it lets a renderer say "this note uses
6
+ * pack `ana`, which is not installed" instead of showing an unexplained
7
+ * fallback box. It does not load, register, or validate components — it
8
+ * just answers "of the packs this note declared, which ones do I have?".
9
+ *
10
+ * Pure and dependency-free, mirroring the rest of this package: no
11
+ * filesystem, no registry, no host coupling.
12
+ */
13
+ import { validatePackName } from './namespace.js';
14
+ /**
15
+ * Resolves a note's declared `uses:` list against the pack namespaces a
16
+ * host has installed. Never throws.
17
+ *
18
+ * - `declaredUses` is `undefined` when the note declared no `uses:` at all;
19
+ * `resolveUses` returns `{ declared: false, missing: [], satisfied: [] }`
20
+ * in that case without inspecting `installedNamespaces`.
21
+ * - `declaredUses: []` ("declared, names no packs") returns
22
+ * `{ declared: true, missing: [], satisfied: [] }`.
23
+ * - `installedNamespaces` is consumed into a `Set` once, so any iterable
24
+ * works (an array, another `Set`, a generator) and lookups are O(1)
25
+ * regardless of how many packs are installed. A `Set` built from
26
+ * attacker-controlled strings (e.g. `"__proto__"`) is a safe lookup
27
+ * target: `Set.prototype.has` does not consult the prototype chain the
28
+ * way a plain-object property read would, so no `Object.hasOwn` guard is
29
+ * needed here (unlike `namespace.ts`'s object-key readers).
30
+ * - Duplicate names in `declaredUses` are de-duplicated; the first
31
+ * occurrence's position is what determines its place in the output
32
+ * arrays, so host messaging ("missing: ana, gh") stays stable regardless
33
+ * of how many times a note repeats a name.
34
+ * - Neither input is mutated.
35
+ */
36
+ export function resolveUses(declaredUses, installedNamespaces) {
37
+ if (declaredUses === undefined) {
38
+ return { declared: false, missing: [], satisfied: [] };
39
+ }
40
+ const installed = new Set(installedNamespaces);
41
+ const seen = new Set();
42
+ const missing = [];
43
+ const satisfied = [];
44
+ for (const name of declaredUses) {
45
+ if (seen.has(name))
46
+ continue;
47
+ seen.add(name);
48
+ if (installed.has(name)) {
49
+ satisfied.push(name);
50
+ }
51
+ else {
52
+ missing.push(name);
53
+ }
54
+ }
55
+ return { declared: true, missing, satisfied };
56
+ }
57
+ /**
58
+ * Convenience re-export point for callers that want to flag a declared name
59
+ * that could never be a valid pack namespace in the first place (distinct
60
+ * from "valid namespace, just not installed"). `resolveUses` itself does not
61
+ * draw this distinction — see `UsesResolution`'s docs — so a host that wants
62
+ * it filters `missing` through this predicate.
63
+ */
64
+ export function isValidPackNameShape(name) {
65
+ return validatePackName(name).ok;
66
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@markii/pack",
3
+ "version": "0.7.0",
4
+ "description": "Component pack contract for Markii (docs/packs.md): the pack.json manifest shape, namespace and engine rules, and hand-rolled validation. No React, no parsing, no bundle loading.",
5
+ "keywords": [
6
+ "markdown",
7
+ "mark",
8
+ "mk.md",
9
+ "pack",
10
+ "namespace"
11
+ ],
12
+ "license": "MIT",
13
+ "author": "sadigaxund",
14
+ "homepage": "https://github.com/sadigaxund/markii#readme",
15
+ "bugs": "https://github.com/sadigaxund/markii/issues",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/sadigaxund/markii.git",
19
+ "directory": "packages/markii-pack"
20
+ },
21
+ "type": "module",
22
+ "main": "./dist/index.js",
23
+ "module": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "import": "./dist/index.js",
29
+ "default": "./dist/index.js"
30
+ }
31
+ },
32
+ "files": [
33
+ "dist"
34
+ ],
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "scripts": {
39
+ "test": "vitest run",
40
+ "build": "tsc --noEmit -p tsconfig.json",
41
+ "build:dist": "rm -rf dist && tsc -p tsconfig.build.json",
42
+ "lint": "eslint ."
43
+ }
44
+ }