@markii/pack 0.9.0 → 0.10.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.
@@ -18,16 +18,64 @@ export type PackComponentKind = 'inline' | 'leaf' | 'container';
18
18
  * spelled differently in two places within this package and drift apart.
19
19
  */
20
20
  export declare const PACK_COMPONENT_KINDS: readonly PackComponentKind[];
21
+ /**
22
+ * The charset a pack's declared attribute name must match: a leading
23
+ * lowercase letter, then lowercase letters, digits, and hyphens. Looser
24
+ * than `namespace.ts`'s `SEGMENT_RE` on purpose, because an attribute name
25
+ * is not a namespace: it never composes with another segment, so a
26
+ * trailing or doubled hyphen is ugly rather than ambiguous, and there is
27
+ * nothing for a stricter rule to protect.
28
+ *
29
+ * No prototype-member rejection here, unlike `validatePackName`: declared
30
+ * attributes travel as an ARRAY, never as an object keyed by attribute
31
+ * name, so an attribute called `constructor` is only ever compared as a
32
+ * string and can never reach a prototype chain.
33
+ */
34
+ export declare const PACK_ATTRIBUTE_NAME_PATTERN: RegExp;
35
+ /** Whether `name` is a usable pack attribute name. Never throws; a non-string is simply `false`. */
36
+ export declare function isPackAttributeName(name: unknown): name is string;
37
+ /**
38
+ * One attribute a pack component declares in `pack.json`, so a host's
39
+ * completion, hover, and insert skeleton can offer it the same way they
40
+ * offer a standard component's contract attributes. Every field but `name`
41
+ * is optional: a pack that declares only names still gets attribute-name
42
+ * completion.
43
+ *
44
+ * This is metadata, not enforcement. Nothing validates a rendered
45
+ * directive's attributes against it: the renderer stays registry-driven
46
+ * and a component owns how it reads its own props (Architecture rules 2
47
+ * and 5). A `required` attribute an author leaves out is the component's
48
+ * problem to handle, exactly as before.
49
+ */
50
+ export interface PackComponentAttribute {
51
+ /** The name an author writes inside `{...}`, e.g. `from`. Matches `PACK_ATTRIBUTE_NAME_PATTERN`. */
52
+ readonly name: string;
53
+ /** Human-readable semantics, shown in a completion row and in hover documentation. */
54
+ readonly description?: string;
55
+ /** `true` when the component is incomplete without this attribute. A required attribute is pre-filled by the insert skeleton. */
56
+ readonly required?: boolean;
57
+ /** The closed set of allowed values, when the attribute is an enum. Non-empty when present; offered as attribute-value completions. */
58
+ readonly values?: readonly string[];
59
+ /** The value used when the attribute is absent. When `values` is present, this is one of them. */
60
+ readonly default?: string;
61
+ }
21
62
  /**
22
63
  * The object form of a `components` manifest entry. `source` is the only
23
64
  * required field (a pack-relative path, same meaning as the string
24
- * shorthand); `description` and `kind` are optional metadata a host can
25
- * surface in an insert-component catalog or similar UI.
65
+ * shorthand); `description`, `kind`, and `attributes` are optional metadata
66
+ * a host can surface in an insert-component catalog, a completion popup, or
67
+ * hover documentation.
26
68
  */
27
69
  export interface PackComponentDefinition {
28
70
  source: string;
29
71
  description?: string;
30
72
  kind?: PackComponentKind;
73
+ /**
74
+ * The component's declared attributes, in the order a host should offer
75
+ * them. Optional, and omitted rather than empty when the manifest
76
+ * declared none: `attributes: []` normalizes to absent.
77
+ */
78
+ attributes?: readonly PackComponentAttribute[];
31
79
  }
32
80
  /**
33
81
  * A `components` manifest entry: either the original string shorthand
@@ -37,13 +85,14 @@ export interface PackComponentDefinition {
37
85
  export type PackComponentEntry = string | PackComponentDefinition;
38
86
  /**
39
87
  * One component's declared metadata with both entry forms normalized to
40
- * the same shape. `description`/`kind` are only present when the manifest
41
- * declared them (the string shorthand never has either).
88
+ * the same shape. `description`/`kind`/`attributes` are only present when
89
+ * the manifest declared them (the string shorthand never has any of them).
42
90
  */
43
91
  export interface ResolvedPackComponent {
44
92
  readonly source: string;
45
93
  readonly description?: string;
46
94
  readonly kind?: PackComponentKind;
95
+ readonly attributes?: readonly PackComponentAttribute[];
47
96
  }
48
97
  /** Same as `ResolvedPackComponent`, plus the local name it was declared under. */
49
98
  export interface PackComponentListing extends ResolvedPackComponent {
@@ -11,6 +11,93 @@ export const PACK_COMPONENT_KINDS = [
11
11
  'container',
12
12
  ];
13
13
  const PACK_COMPONENT_KIND_SET = new Set(PACK_COMPONENT_KINDS);
14
+ /**
15
+ * The charset a pack's declared attribute name must match: a leading
16
+ * lowercase letter, then lowercase letters, digits, and hyphens. Looser
17
+ * than `namespace.ts`'s `SEGMENT_RE` on purpose, because an attribute name
18
+ * is not a namespace: it never composes with another segment, so a
19
+ * trailing or doubled hyphen is ugly rather than ambiguous, and there is
20
+ * nothing for a stricter rule to protect.
21
+ *
22
+ * No prototype-member rejection here, unlike `validatePackName`: declared
23
+ * attributes travel as an ARRAY, never as an object keyed by attribute
24
+ * name, so an attribute called `constructor` is only ever compared as a
25
+ * string and can never reach a prototype chain.
26
+ */
27
+ export const PACK_ATTRIBUTE_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
28
+ /** Whether `name` is a usable pack attribute name. Never throws; a non-string is simply `false`. */
29
+ export function isPackAttributeName(name) {
30
+ return typeof name === 'string' && PACK_ATTRIBUTE_NAME_PATTERN.test(name);
31
+ }
32
+ /**
33
+ * Reads one own property off an already-narrowed object, or `undefined`
34
+ * when it is absent. `Object.hasOwn` only, matching this package's rule
35
+ * throughout: an inherited property is never read, so an object built with
36
+ * a hostile prototype cannot smuggle a value through the prototype chain.
37
+ */
38
+ function ownProperty(entry, key) {
39
+ return Object.hasOwn(entry, key)
40
+ ? entry[key]
41
+ : undefined;
42
+ }
43
+ /**
44
+ * The lenient half of attribute handling, for `resolvePackComponent`: a
45
+ * malformed attribute entry is dropped rather than rejected, the same way
46
+ * a malformed `description` or `kind` is simply omitted here. The strict
47
+ * half lives in `./manifest.ts`, which rejects the whole manifest instead
48
+ * so a pack author sees their mistake at validation time rather than
49
+ * silently losing an attribute from a completion popup.
50
+ */
51
+ function resolveAttribute(entry) {
52
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
53
+ return undefined;
54
+ }
55
+ const name = ownProperty(entry, 'name');
56
+ if (!isPackAttributeName(name))
57
+ return undefined;
58
+ const result = { name };
59
+ const description = ownProperty(entry, 'description');
60
+ if (typeof description === 'string' && description.length > 0) {
61
+ result.description = description;
62
+ }
63
+ if (ownProperty(entry, 'required') === true) {
64
+ result.required = true;
65
+ }
66
+ const values = ownProperty(entry, 'values');
67
+ if (Array.isArray(values)) {
68
+ const cleaned = values.filter((value) => typeof value === 'string' && value.length > 0);
69
+ if (cleaned.length > 0)
70
+ result.values = cleaned;
71
+ }
72
+ const defaultValue = ownProperty(entry, 'default');
73
+ if (typeof defaultValue === 'string' &&
74
+ defaultValue.length > 0 &&
75
+ (result.values === undefined || result.values.includes(defaultValue))) {
76
+ result.default = defaultValue;
77
+ }
78
+ return result;
79
+ }
80
+ /**
81
+ * Normalizes a declared `attributes` list: keeps declaration order, drops
82
+ * an entry `resolveAttribute` cannot make sense of, drops a duplicate name
83
+ * after the first, and returns `undefined` (rather than `[]`) when nothing
84
+ * survives, so "declared no attributes" and "declared an empty list" are
85
+ * the same state everywhere downstream.
86
+ */
87
+ function resolveAttributes(raw) {
88
+ if (!Array.isArray(raw))
89
+ return undefined;
90
+ const seen = new Set();
91
+ const resolved = [];
92
+ for (const entry of raw) {
93
+ const attribute = resolveAttribute(entry);
94
+ if (attribute === undefined || seen.has(attribute.name))
95
+ continue;
96
+ seen.add(attribute.name);
97
+ resolved.push(attribute);
98
+ }
99
+ return resolved.length > 0 ? resolved : undefined;
100
+ }
14
101
  /**
15
102
  * Normalizes a single `components` entry, string or object form, into the
16
103
  * common `ResolvedPackComponent` shape. This is THE accessor for reading
@@ -33,30 +120,25 @@ export function resolvePackComponent(entry) {
33
120
  if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
34
121
  return undefined;
35
122
  }
36
- // Object.hasOwn only, matching this package's rule throughout: never read
37
- // an inherited property, so an object built with a hostile prototype
38
- // cannot smuggle a `source` through the prototype chain.
39
- const source = Object.hasOwn(entry, 'source')
40
- ? entry.source
41
- : undefined;
123
+ const source = ownProperty(entry, 'source');
42
124
  if (typeof source !== 'string' || source.length === 0) {
43
125
  return undefined;
44
126
  }
45
127
  const result = {
46
128
  source,
47
129
  };
48
- const description = Object.hasOwn(entry, 'description')
49
- ? entry.description
50
- : undefined;
130
+ const description = ownProperty(entry, 'description');
51
131
  if (typeof description === 'string' && description.length > 0) {
52
132
  result.description = description;
53
133
  }
54
- const kind = Object.hasOwn(entry, 'kind')
55
- ? entry.kind
56
- : undefined;
134
+ const kind = ownProperty(entry, 'kind');
57
135
  if (typeof kind === 'string' && PACK_COMPONENT_KIND_SET.has(kind)) {
58
136
  result.kind = kind;
59
137
  }
138
+ const attributes = resolveAttributes(ownProperty(entry, 'attributes'));
139
+ if (attributes !== undefined) {
140
+ result.attributes = attributes;
141
+ }
60
142
  return result;
61
143
  }
62
144
  /**
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export type { PackManifest, PackManifestParseResult } from './manifest.js';
2
2
  export { parsePackManifest } from './manifest.js';
3
- export type { PackComponentDefinition, PackComponentEntry, PackComponentKind, PackComponentListing, ResolvedPackComponent, } from './components.js';
4
- export { PACK_COMPONENT_KINDS, packComponents, resolvePackComponent, } from './components.js';
3
+ export type { PackComponentAttribute, PackComponentDefinition, PackComponentEntry, PackComponentKind, PackComponentListing, ResolvedPackComponent, } from './components.js';
4
+ export { PACK_ATTRIBUTE_NAME_PATTERN, PACK_COMPONENT_KINDS, isPackAttributeName, packComponents, resolvePackComponent, } from './components.js';
5
5
  export type { ComposeDirectiveNameResult, NamespaceCollision, NamespaceValidationResult, } from './namespace.js';
6
6
  export { RESERVED_NAMESPACE_SEGMENTS, composeDirectiveName, detectNamespaceCollisions, validateLocalComponentName, validatePackName, } from './namespace.js';
7
7
  export type { UsesResolution } from './uses.js';
package/dist/index.js CHANGED
@@ -3,6 +3,6 @@
3
3
  // hand-rolled validation only. No registry loading, no `uses:` surfacing,
4
4
  // no sandboxed `require`, no filesystem reads: those are later slices.
5
5
  export { parsePackManifest } from './manifest.js';
6
- export { PACK_COMPONENT_KINDS, packComponents, resolvePackComponent, } from './components.js';
6
+ export { PACK_ATTRIBUTE_NAME_PATTERN, PACK_COMPONENT_KINDS, isPackAttributeName, packComponents, resolvePackComponent, } from './components.js';
7
7
  export { RESERVED_NAMESPACE_SEGMENTS, composeDirectiveName, detectNamespaceCollisions, validateLocalComponentName, validatePackName, } from './namespace.js';
8
8
  export { isValidPackNameShape, resolveUses } from './uses.js';
@@ -11,8 +11,8 @@ import type { PackComponentEntry } from './components.js';
11
11
  * not maintain a closed enum of known engines. `components` maps a local
12
12
  * component name to either a pack-relative source path (the string
13
13
  * shorthand) or an object carrying that source plus optional `description`/
14
- * `kind` metadata (see `./components.ts`, `resolvePackComponent`,
15
- * `packComponents`).
14
+ * `kind`/`attributes` metadata (see `./components.ts`,
15
+ * `resolvePackComponent`, `packComponents`).
16
16
  *
17
17
  * Shared Lua modules are NOT a manifest field. docs/packs.md says they
18
18
  * travel under a conventional `scripts/` directory inside the pack
@@ -28,7 +28,8 @@ export interface PackManifest {
28
28
  engine: string;
29
29
  /**
30
30
  * Local component name -> a pack-relative source path, or an object
31
- * naming that source plus optional `description`/`kind` metadata. Read
31
+ * naming that source plus optional `description`/`kind`/`attributes`
32
+ * metadata. Read
32
33
  * this map only through `resolvePackComponent`/`packComponents`
33
34
  * (`./components.ts`) rather than inspecting entries directly — that is
34
35
  * the one place both forms are normalized.
package/dist/manifest.js CHANGED
@@ -1,10 +1,25 @@
1
1
  import { validateLocalComponentName, validatePackName } from './namespace.js';
2
- import { PACK_COMPONENT_KINDS } from './components.js';
2
+ import { PACK_ATTRIBUTE_NAME_PATTERN, PACK_COMPONENT_KINDS, isPackAttributeName, } from './components.js';
3
3
  const KNOWN_TOP_LEVEL_KEYS = new Set(['name', 'engine', 'components']);
4
4
  // The keys a component's object-form entry may carry, and which of those
5
5
  // are required. Mirrors KNOWN_TOP_LEVEL_KEYS's role for the manifest root:
6
6
  // an unrecognized key here is a forward-compatible warning, not an error.
7
- const KNOWN_COMPONENT_KEYS = new Set(['source', 'description', 'kind']);
7
+ const KNOWN_COMPONENT_KEYS = new Set([
8
+ 'source',
9
+ 'description',
10
+ 'kind',
11
+ 'attributes',
12
+ ]);
13
+ // The keys one entry of a component's `attributes` array may carry. Same
14
+ // posture as KNOWN_COMPONENT_KEYS one level up: an unrecognized key is a
15
+ // forward-compatible warning, everything recognized is validated strictly.
16
+ const KNOWN_ATTRIBUTE_KEYS = new Set([
17
+ 'name',
18
+ 'description',
19
+ 'required',
20
+ 'values',
21
+ 'default',
22
+ ]);
8
23
  // Checked against ./components.ts's single runtime list rather than a second
9
24
  // copy of the three values, so the validator and the accessor can never
10
25
  // disagree about what a valid `kind` is.
@@ -12,6 +27,118 @@ const PACK_COMPONENT_KIND_SET = new Set(PACK_COMPONENT_KINDS);
12
27
  function isPlainObject(value) {
13
28
  return value !== null && typeof value === 'object' && !Array.isArray(value);
14
29
  }
30
+ /**
31
+ * The strict half of attribute validation (the lenient half lives in
32
+ * `./components.ts`'s `resolveAttribute`, which a consumer may call on
33
+ * never-validated JSON). Anything malformed here pushes an error and
34
+ * rejects the WHOLE manifest, so a pack author is told what is wrong at
35
+ * validation time rather than quietly losing an attribute from a
36
+ * completion popup later.
37
+ *
38
+ * Returns the sanitized list, or `undefined` when anything failed. An
39
+ * empty declared list is valid and returns `[]`; the caller drops that
40
+ * rather than storing it, so "declared nothing" and "declared an empty
41
+ * list" are one state downstream.
42
+ */
43
+ function parseComponentAttributes(componentKey, raw, errors, warnings) {
44
+ const parsed = [];
45
+ const seen = new Set();
46
+ let valid = true;
47
+ for (const [index, entry] of raw.entries()) {
48
+ const where = `"components.${componentKey}.attributes[${String(index)}]"`;
49
+ if (!isPlainObject(entry)) {
50
+ errors.push(`${where} must be an object with a "name" field`);
51
+ valid = false;
52
+ continue;
53
+ }
54
+ const rawName = Object.hasOwn(entry, 'name') ? entry.name : undefined;
55
+ if (!isPackAttributeName(rawName)) {
56
+ errors.push(`${where}.name must be a non-empty string matching ${PACK_ATTRIBUTE_NAME_PATTERN.source}`);
57
+ valid = false;
58
+ continue;
59
+ }
60
+ if (seen.has(rawName)) {
61
+ errors.push(`${where}.name "${rawName}" is declared more than once`);
62
+ valid = false;
63
+ continue;
64
+ }
65
+ seen.add(rawName);
66
+ let entryValid = true;
67
+ let description;
68
+ if (Object.hasOwn(entry, 'description')) {
69
+ const rawDescription = entry.description;
70
+ if (typeof rawDescription !== 'string' || rawDescription.length === 0) {
71
+ errors.push(`${where}.description must be a non-empty string`);
72
+ entryValid = false;
73
+ }
74
+ else {
75
+ description = rawDescription;
76
+ }
77
+ }
78
+ let required;
79
+ if (Object.hasOwn(entry, 'required')) {
80
+ const rawRequired = entry.required;
81
+ if (typeof rawRequired !== 'boolean') {
82
+ errors.push(`${where}.required must be a boolean`);
83
+ entryValid = false;
84
+ }
85
+ else {
86
+ required = rawRequired;
87
+ }
88
+ }
89
+ let values;
90
+ if (Object.hasOwn(entry, 'values')) {
91
+ const rawValues = entry.values;
92
+ if (!Array.isArray(rawValues) ||
93
+ rawValues.length === 0 ||
94
+ rawValues.some((value) => typeof value !== 'string' || value.length === 0)) {
95
+ errors.push(`${where}.values must be a non-empty array of non-empty strings`);
96
+ entryValid = false;
97
+ }
98
+ else {
99
+ values = rawValues;
100
+ }
101
+ }
102
+ let defaultValue;
103
+ if (Object.hasOwn(entry, 'default')) {
104
+ const rawDefault = entry.default;
105
+ if (typeof rawDefault !== 'string' || rawDefault.length === 0) {
106
+ errors.push(`${where}.default must be a non-empty string`);
107
+ entryValid = false;
108
+ }
109
+ else if (values !== undefined && !values.includes(rawDefault)) {
110
+ errors.push(`${where}.default "${rawDefault}" must be one of its "values": ${values.join(', ')}`);
111
+ entryValid = false;
112
+ }
113
+ else {
114
+ defaultValue = rawDefault;
115
+ }
116
+ }
117
+ for (const attributeKey of Object.keys(entry)) {
118
+ if (Object.hasOwn(entry, attributeKey) &&
119
+ !KNOWN_ATTRIBUTE_KEYS.has(attributeKey)) {
120
+ warnings.push(`unknown pack component attribute key ${where}.${attributeKey} (ignored by this implementation)`);
121
+ }
122
+ }
123
+ if (!entryValid) {
124
+ valid = false;
125
+ continue;
126
+ }
127
+ // Rebuilt rather than stored raw, the same discipline the component
128
+ // object form uses: only checked fields ever reach the result.
129
+ const sanitized = { name: rawName };
130
+ if (description !== undefined)
131
+ sanitized.description = description;
132
+ if (required !== undefined)
133
+ sanitized.required = required;
134
+ if (values !== undefined)
135
+ sanitized.values = values;
136
+ if (defaultValue !== undefined)
137
+ sanitized.default = defaultValue;
138
+ parsed.push(sanitized);
139
+ }
140
+ return valid ? parsed : undefined;
141
+ }
15
142
  /**
16
143
  * Hand-rolled `pack.json` validation (no schema library — see AGENTS.md
17
144
  * dependency policy), mirroring `@markii/bundle`'s `parseManifest` style:
@@ -73,9 +200,10 @@ export function parsePackManifest(json) {
73
200
  // --- components (required) ---
74
201
  //
75
202
  // Each entry is either the string shorthand (a pack-relative source path)
76
- // or an object form { source, description?, kind? }. A malformed entry —
77
- // wrong value type, invalid key, bad `kind`, non-string `description`,
78
- // missing/empty `source` in object form — pushes an error and rejects the
203
+ // or an object form { source, description?, kind?, attributes? }. A
204
+ // malformed entry — wrong value type, invalid key, bad `kind`, non-string
205
+ // `description`, a malformed `attributes` list, missing/empty `source` in
206
+ // object form — pushes an error and rejects the
79
207
  // WHOLE manifest, matching this file's existing posture for a bad
80
208
  // top-level field. An unrecognized key INSIDE a component object is a
81
209
  // forward-compatible warning, the same posture KNOWN_TOP_LEVEL_KEYS uses
@@ -156,6 +284,23 @@ export function parsePackManifest(json) {
156
284
  kind = rawKind;
157
285
  }
158
286
  }
287
+ let attributes;
288
+ if (Object.hasOwn(value, 'attributes')) {
289
+ const rawAttributes = value.attributes;
290
+ if (!Array.isArray(rawAttributes)) {
291
+ errors.push(`"components.${key}.attributes" must be an array of attribute objects`);
292
+ entryValid = false;
293
+ }
294
+ else {
295
+ const parsed = parseComponentAttributes(key, rawAttributes, errors, warnings);
296
+ if (parsed === undefined) {
297
+ entryValid = false;
298
+ }
299
+ else if (parsed.length > 0) {
300
+ attributes = parsed;
301
+ }
302
+ }
303
+ }
159
304
  for (const componentKey of Object.keys(value)) {
160
305
  if (Object.hasOwn(value, componentKey) &&
161
306
  !KNOWN_COMPONENT_KEYS.has(componentKey)) {
@@ -175,6 +320,8 @@ export function parsePackManifest(json) {
175
320
  sanitized.description = description;
176
321
  if (kind !== undefined)
177
322
  sanitized.kind = kind;
323
+ if (attributes !== undefined)
324
+ sanitized.attributes = attributes;
178
325
  result[key] = sanitized;
179
326
  }
180
327
  if (Object.keys(componentsObj).length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markii/pack",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
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
5
  "keywords": [
6
6
  "markdown",