@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 +21 -0
- package/README.md +5 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +7 -0
- package/dist/manifest.d.ts +50 -0
- package/dist/manifest.js +123 -0
- package/dist/namespace.d.ts +86 -0
- package/dist/namespace.js +176 -0
- package/dist/uses.d.ts +68 -0
- package/dist/uses.js +66 -0
- package/package.json +44 -0
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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;
|
package/dist/manifest.js
ADDED
|
@@ -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
|
+
}
|