@markii/pack 0.7.2 → 0.8.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/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  # @markii/pack
2
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.
3
+ Component pack contract for [Markii](https://github.com/markii-org/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
4
 
5
- See the [repository](https://github.com/sadigaxund/markii) for the format spec and the reference library as a whole.
5
+ See the [repository](https://github.com/markii-org/markii) for the format spec and the reference library as a whole.
@@ -0,0 +1,81 @@
1
+ import type { PackManifest } from './manifest.js';
2
+ /**
3
+ * Which directive form a pack component is written as. Mirrors
4
+ * `@markii/stdlib`'s `ComponentKind`; defined locally here rather than
5
+ * imported because `@markii/pack` is a published, zero-dependency package
6
+ * (see AGENTS.md's Stack section) and `@markii/stdlib` is a sibling
7
+ * package, not a dependency of this one. The two unions must stay aligned;
8
+ * `@markii/host` (which depends on both) carries an executable check of
9
+ * that alignment, since this package cannot import `@markii/stdlib` to
10
+ * check it itself.
11
+ */
12
+ export type PackComponentKind = 'inline' | 'leaf' | 'container';
13
+ /**
14
+ * The three `kind` values, as runtime data. This is the ONE list: the
15
+ * manifest validator (`./manifest.ts`) checks a declared `kind` against it,
16
+ * and `@markii/host`'s alignment test compares it against
17
+ * `@markii/stdlib`'s `ComponentKind` members, so the values cannot be
18
+ * spelled differently in two places within this package and drift apart.
19
+ */
20
+ export declare const PACK_COMPONENT_KINDS: readonly PackComponentKind[];
21
+ /**
22
+ * The object form of a `components` manifest entry. `source` is the only
23
+ * 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.
26
+ */
27
+ export interface PackComponentDefinition {
28
+ source: string;
29
+ description?: string;
30
+ kind?: PackComponentKind;
31
+ }
32
+ /**
33
+ * A `components` manifest entry: either the original string shorthand
34
+ * (source path only) or the object form carrying optional metadata
35
+ * alongside the source.
36
+ */
37
+ export type PackComponentEntry = string | PackComponentDefinition;
38
+ /**
39
+ * 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).
42
+ */
43
+ export interface ResolvedPackComponent {
44
+ readonly source: string;
45
+ readonly description?: string;
46
+ readonly kind?: PackComponentKind;
47
+ }
48
+ /** Same as `ResolvedPackComponent`, plus the local name it was declared under. */
49
+ export interface PackComponentListing extends ResolvedPackComponent {
50
+ readonly localName: string;
51
+ }
52
+ /**
53
+ * Normalizes a single `components` entry, string or object form, into the
54
+ * common `ResolvedPackComponent` shape. This is THE accessor for reading
55
+ * one entry — callers should never inline a `typeof entry === 'string'`
56
+ * check of their own, so the two forms cannot silently drift apart across
57
+ * consumers.
58
+ *
59
+ * Never throws and never assumes the input has already been validated by
60
+ * `parsePackManifest`: a hostile or malformed value (not a string, not a
61
+ * plain object, an object missing `source`, a `source` that is not a
62
+ * non-empty string) returns `undefined` rather than throwing or fabricating
63
+ * a value. This matters because a consumer may call this directly on
64
+ * attacker-controlled JSON that never went through `parsePackManifest` at
65
+ * all (for example a pack loaded from an untrusted bundle).
66
+ */
67
+ export declare function resolvePackComponent(entry: unknown): ResolvedPackComponent | undefined;
68
+ /**
69
+ * THE iterator every consumer uses instead of walking `manifest.components`
70
+ * directly: `Object.hasOwn`-guarded, in declaration key order, skipping any
71
+ * entry `resolvePackComponent` cannot resolve rather than throwing. This is
72
+ * the only sanctioned way to enumerate a pack's components — see
73
+ * `discover.ts`, `pack-build.ts`, `pack-diagnostics.ts`, and
74
+ * `component-catalog.ts` in `@markii/host`, all of which call this instead
75
+ * of re-implementing the walk.
76
+ *
77
+ * Tolerates a manifest whose `components` field is missing, `null`, not an
78
+ * object, or an array: all of those return `[]` rather than throwing, so a
79
+ * caller never needs to guard the call site itself.
80
+ */
81
+ export declare function packComponents(manifest: Pick<PackManifest, 'components'>): PackComponentListing[];
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The three `kind` values, as runtime data. This is the ONE list: the
3
+ * manifest validator (`./manifest.ts`) checks a declared `kind` against it,
4
+ * and `@markii/host`'s alignment test compares it against
5
+ * `@markii/stdlib`'s `ComponentKind` members, so the values cannot be
6
+ * spelled differently in two places within this package and drift apart.
7
+ */
8
+ export const PACK_COMPONENT_KINDS = [
9
+ 'inline',
10
+ 'leaf',
11
+ 'container',
12
+ ];
13
+ const PACK_COMPONENT_KIND_SET = new Set(PACK_COMPONENT_KINDS);
14
+ /**
15
+ * Normalizes a single `components` entry, string or object form, into the
16
+ * common `ResolvedPackComponent` shape. This is THE accessor for reading
17
+ * one entry — callers should never inline a `typeof entry === 'string'`
18
+ * check of their own, so the two forms cannot silently drift apart across
19
+ * consumers.
20
+ *
21
+ * Never throws and never assumes the input has already been validated by
22
+ * `parsePackManifest`: a hostile or malformed value (not a string, not a
23
+ * plain object, an object missing `source`, a `source` that is not a
24
+ * non-empty string) returns `undefined` rather than throwing or fabricating
25
+ * a value. This matters because a consumer may call this directly on
26
+ * attacker-controlled JSON that never went through `parsePackManifest` at
27
+ * all (for example a pack loaded from an untrusted bundle).
28
+ */
29
+ export function resolvePackComponent(entry) {
30
+ if (typeof entry === 'string') {
31
+ return entry.length > 0 ? { source: entry } : undefined;
32
+ }
33
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
34
+ return undefined;
35
+ }
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;
42
+ if (typeof source !== 'string' || source.length === 0) {
43
+ return undefined;
44
+ }
45
+ const result = {
46
+ source,
47
+ };
48
+ const description = Object.hasOwn(entry, 'description')
49
+ ? entry.description
50
+ : undefined;
51
+ if (typeof description === 'string' && description.length > 0) {
52
+ result.description = description;
53
+ }
54
+ const kind = Object.hasOwn(entry, 'kind')
55
+ ? entry.kind
56
+ : undefined;
57
+ if (typeof kind === 'string' && PACK_COMPONENT_KIND_SET.has(kind)) {
58
+ result.kind = kind;
59
+ }
60
+ return result;
61
+ }
62
+ /**
63
+ * THE iterator every consumer uses instead of walking `manifest.components`
64
+ * directly: `Object.hasOwn`-guarded, in declaration key order, skipping any
65
+ * entry `resolvePackComponent` cannot resolve rather than throwing. This is
66
+ * the only sanctioned way to enumerate a pack's components — see
67
+ * `discover.ts`, `pack-build.ts`, `pack-diagnostics.ts`, and
68
+ * `component-catalog.ts` in `@markii/host`, all of which call this instead
69
+ * of re-implementing the walk.
70
+ *
71
+ * Tolerates a manifest whose `components` field is missing, `null`, not an
72
+ * object, or an array: all of those return `[]` rather than throwing, so a
73
+ * caller never needs to guard the call site itself.
74
+ */
75
+ export function packComponents(manifest) {
76
+ const components = manifest?.components;
77
+ if (components === null ||
78
+ typeof components !== 'object' ||
79
+ Array.isArray(components)) {
80
+ return [];
81
+ }
82
+ const listings = [];
83
+ for (const localName of Object.keys(components)) {
84
+ if (!Object.hasOwn(components, localName))
85
+ continue;
86
+ const resolved = resolvePackComponent(components[localName]);
87
+ if (resolved === undefined)
88
+ continue;
89
+ listings.push({ localName, ...resolved });
90
+ }
91
+ return listings;
92
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +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
5
  export type { ComposeDirectiveNameResult, NamespaceCollision, NamespaceValidationResult, } from './namespace.js';
4
6
  export { RESERVED_NAMESPACE_SEGMENTS, composeDirectiveName, detectNamespaceCollisions, validateLocalComponentName, validatePackName, } from './namespace.js';
5
7
  export type { UsesResolution } from './uses.js';
package/dist/index.js CHANGED
@@ -3,5 +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
7
  export { RESERVED_NAMESPACE_SEGMENTS, composeDirectiveName, detectNamespaceCollisions, validateLocalComponentName, validatePackName, } from './namespace.js';
7
8
  export { isValidPackNameShape, resolveUses } from './uses.js';
@@ -1,3 +1,4 @@
1
+ import type { PackComponentEntry } from './components.js';
1
2
  /**
2
3
  * `pack.json`'s contract (docs/packs.md). Three fields, all required:
3
4
  *
@@ -8,7 +9,10 @@
8
9
  * renderer framework the pack's components are written for; it is any
9
10
  * string — the host decides whether it can run it, so this package does
10
11
  * not maintain a closed enum of known engines. `components` maps a local
11
- * component name to a pack-relative source path.
12
+ * component name to either a pack-relative source path (the string
13
+ * shorthand) or an object carrying that source plus optional `description`/
14
+ * `kind` metadata (see `./components.ts`, `resolvePackComponent`,
15
+ * `packComponents`).
12
16
  *
13
17
  * Shared Lua modules are NOT a manifest field. docs/packs.md says they
14
18
  * travel under a conventional `scripts/` directory inside the pack
@@ -22,8 +26,14 @@ export interface PackManifest {
22
26
  name: string;
23
27
  /** The renderer framework the pack's components target, e.g. `"react"`. */
24
28
  engine: string;
25
- /** Local component name -> pack-relative source path. */
26
- components: Record<string, string>;
29
+ /**
30
+ * Local component name -> a pack-relative source path, or an object
31
+ * naming that source plus optional `description`/`kind` metadata. Read
32
+ * this map only through `resolvePackComponent`/`packComponents`
33
+ * (`./components.ts`) rather than inspecting entries directly — that is
34
+ * the one place both forms are normalized.
35
+ */
36
+ components: Record<string, PackComponentEntry>;
27
37
  }
28
38
  export type PackManifestParseResult = {
29
39
  ok: true;
package/dist/manifest.js CHANGED
@@ -1,5 +1,14 @@
1
1
  import { validateLocalComponentName, validatePackName } from './namespace.js';
2
+ import { PACK_COMPONENT_KINDS } from './components.js';
2
3
  const KNOWN_TOP_LEVEL_KEYS = new Set(['name', 'engine', 'components']);
4
+ // The keys a component's object-form entry may carry, and which of those
5
+ // are required. Mirrors KNOWN_TOP_LEVEL_KEYS's role for the manifest root:
6
+ // an unrecognized key here is a forward-compatible warning, not an error.
7
+ const KNOWN_COMPONENT_KEYS = new Set(['source', 'description', 'kind']);
8
+ // Checked against ./components.ts's single runtime list rather than a second
9
+ // copy of the three values, so the validator and the accessor can never
10
+ // disagree about what a valid `kind` is.
11
+ const PACK_COMPONENT_KIND_SET = new Set(PACK_COMPONENT_KINDS);
3
12
  function isPlainObject(value) {
4
13
  return value !== null && typeof value === 'object' && !Array.isArray(value);
5
14
  }
@@ -62,6 +71,18 @@ export function parsePackManifest(json) {
62
71
  engine = obj.engine;
63
72
  }
64
73
  // --- components (required) ---
74
+ //
75
+ // 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
79
+ // WHOLE manifest, matching this file's existing posture for a bad
80
+ // top-level field. An unrecognized key INSIDE a component object is a
81
+ // forward-compatible warning, the same posture KNOWN_TOP_LEVEL_KEYS uses
82
+ // for the manifest root. Only the checked, sanitized fields are ever
83
+ // copied into `result`/the rebuilt component object — the raw parsed
84
+ // value is never stored, so an unknown or hostile key on a component
85
+ // object can never surface later even as inert data.
65
86
  let components;
66
87
  if (!Object.hasOwn(obj, 'components')) {
67
88
  errors.push('"components" is required');
@@ -83,17 +104,78 @@ export function parsePackManifest(json) {
83
104
  continue;
84
105
  }
85
106
  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)`);
107
+ if (typeof value === 'string') {
108
+ if (value.length === 0) {
109
+ errors.push(`"components.${key}" must be a non-empty string (a pack-relative source path) or an object with a "source" field`);
110
+ componentsValid = false;
111
+ continue;
112
+ }
113
+ // Only the type is checked here, matching @markii/bundle's
114
+ // `document` field: path-jailing a pack-relative source path is a
115
+ // filesystem concern for the later registry-loading slice, not
116
+ // this contract-only one. Object.hasOwn above already keeps
117
+ // prototype members out of `result`.
118
+ result[key] = value;
119
+ continue;
120
+ }
121
+ if (!isPlainObject(value)) {
122
+ errors.push(`"components.${key}" must be a non-empty string (a pack-relative source path) or an object with a "source" field`);
123
+ componentsValid = false;
124
+ continue;
125
+ }
126
+ // Object form: validate source (required), description (optional),
127
+ // kind (optional), and rebuild a sanitized object rather than storing
128
+ // the raw parsed one — the same discipline as the string-form branch
129
+ // and as the top-level Object.hasOwn guarding throughout this file.
130
+ let entryValid = true;
131
+ const source = Object.hasOwn(value, 'source') ? value.source : undefined;
132
+ if (typeof source !== 'string' || source.length === 0) {
133
+ errors.push(`"components.${key}.source" must be a non-empty string (a pack-relative source path)`);
134
+ entryValid = false;
135
+ }
136
+ let description;
137
+ if (Object.hasOwn(value, 'description')) {
138
+ const rawDescription = value.description;
139
+ if (typeof rawDescription !== 'string' || rawDescription.length === 0) {
140
+ errors.push(`"components.${key}.description" must be a non-empty string`);
141
+ entryValid = false;
142
+ }
143
+ else {
144
+ description = rawDescription;
145
+ }
146
+ }
147
+ let kind;
148
+ if (Object.hasOwn(value, 'kind')) {
149
+ const rawKind = value.kind;
150
+ if (typeof rawKind !== 'string' ||
151
+ !PACK_COMPONENT_KIND_SET.has(rawKind)) {
152
+ errors.push(`"components.${key}.kind" must be one of "inline", "leaf", "container"`);
153
+ entryValid = false;
154
+ }
155
+ else {
156
+ kind = rawKind;
157
+ }
158
+ }
159
+ for (const componentKey of Object.keys(value)) {
160
+ if (Object.hasOwn(value, componentKey) &&
161
+ !KNOWN_COMPONENT_KEYS.has(componentKey)) {
162
+ warnings.push(`unknown pack component key "components.${key}.${componentKey}" (ignored by this implementation)`);
163
+ }
164
+ }
165
+ if (!entryValid) {
88
166
  componentsValid = false;
89
167
  continue;
90
168
  }
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;
169
+ // The object form is kept as an object even when neither optional
170
+ // field was present — only the string shorthand collapses to a bare
171
+ // string. This preserves the author's declared form rather than
172
+ // silently normalizing `{ "source": "./x.tsx" }` into `"./x.tsx"`.
173
+ const sanitized = { source: source };
174
+ if (description !== undefined)
175
+ sanitized.description = description;
176
+ if (kind !== undefined)
177
+ sanitized.kind = kind;
178
+ result[key] = sanitized;
97
179
  }
98
180
  if (Object.keys(componentsObj).length === 0) {
99
181
  errors.push('"components" must have at least one entry');
@@ -3,7 +3,10 @@
3
3
  *
4
4
  * - Directive names MUST NOT contain `:` (reserved syntax).
5
5
  * - A namespaced directive name joins the pack's namespace and the
6
- * component's local name with `-` or `_`.
6
+ * component's local name with `_`, and only `_` (issue #19). The spec
7
+ * permits `-` or `_` for a directive name generally; this package composes
8
+ * exactly one of them, so the boundary between the two parts is always
9
+ * unambiguous. See `composeDirectiveName` for why.
7
10
  * - The bundle's reserved first-path-segment names (`scripts`, `assets`,
8
11
  * `.cache`) can never be a pack namespace, so a pack can never shadow
9
12
  * bundle structure in `require` or in directive names.
@@ -20,11 +23,11 @@
20
23
  * hyphens as separators (no leading/trailing hyphen, no doubled hyphen).
21
24
  * This is deliberately conservative — it's stricter than the spec's bare
22
25
  * "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.
26
+ * compose cleanly with a local name without producing an ambiguous or
27
+ * surprising directive name. Underscore is the composition separator
28
+ * (`ana_timeline`) and is deliberately NOT accepted inside the namespace or
29
+ * local-name segments themselves, which is exactly what makes the composed
30
+ * name's single underscore mark the boundary unambiguously.
28
31
  */
29
32
  /** The reserved bundle directory names a pack namespace must never collide with (spec.md §1, §8). */
30
33
  export declare const RESERVED_NAMESPACE_SEGMENTS: ReadonlySet<string>;
@@ -58,15 +61,29 @@ export type ComposeDirectiveNameResult = {
58
61
  };
59
62
  /**
60
63
  * Composes the directive name an author types to reference a pack's
61
- * component, e.g. `composeDirectiveName('ana', 'timeline')` -> `ana-timeline`
64
+ * component, e.g. `composeDirectiveName('ana', 'timeline')` -> `ana_timeline`
62
65
  * (docs/packs.md). Validates both inputs first, then the composed result
63
66
  * (defense in depth: the composed string must still contain no `:` and must
64
67
  * still be a legal directive-name shape).
65
68
  *
66
- * `separator` defaults to `-`; `_` is also accepted per spec.md §1's "joins
67
- * the namespace and name with `-` or `_`".
69
+ * The separator is `_`, and there is deliberately no way to ask for another
70
+ * one (issue #19, user-settled 2026-08-30). It used to be `-`, which is also
71
+ * the separator INSIDE a lowercase-kebab pack or local name, so two
72
+ * different packs could compose the same directive name: pack `long` plus
73
+ * component `pack-name-some-component` and pack `long-pack-name` plus
74
+ * component `some-component` both produced `long-pack-name-some-component`,
75
+ * and one silently shadowed the other. `SEGMENT_RE` bans `_` inside either
76
+ * segment, so an underscore join gives every composed name exactly one
77
+ * underscore, at the pack/component boundary. That makes the split
78
+ * bijective and this whole collision class impossible to construct rather
79
+ * than merely detected.
80
+ *
81
+ * Accepting both forms would have kept the ambiguity alive, so only this one
82
+ * exists. Notes and packs written against the old `-` composition stop
83
+ * resolving and render the labeled unknown-component fallback; that breakage
84
+ * was weighed and accepted pre-1.0 rather than carrying a migration shim.
68
85
  */
69
- export declare function composeDirectiveName(packName: string, localName: string, separator?: '-' | '_'): ComposeDirectiveNameResult;
86
+ export declare function composeDirectiveName(packName: string, localName: string): ComposeDirectiveNameResult;
70
87
  export interface NamespaceCollision {
71
88
  namespace: string;
72
89
  /** How many times this namespace appears in the input. */
package/dist/namespace.js CHANGED
@@ -3,7 +3,10 @@
3
3
  *
4
4
  * - Directive names MUST NOT contain `:` (reserved syntax).
5
5
  * - A namespaced directive name joins the pack's namespace and the
6
- * component's local name with `-` or `_`.
6
+ * component's local name with `_`, and only `_` (issue #19). The spec
7
+ * permits `-` or `_` for a directive name generally; this package composes
8
+ * exactly one of them, so the boundary between the two parts is always
9
+ * unambiguous. See `composeDirectiveName` for why.
7
10
  * - The bundle's reserved first-path-segment names (`scripts`, `assets`,
8
11
  * `.cache`) can never be a pack namespace, so a pack can never shadow
9
12
  * bundle structure in `require` or in directive names.
@@ -20,11 +23,11 @@
20
23
  * hyphens as separators (no leading/trailing hyphen, no doubled hyphen).
21
24
  * This is deliberately conservative — it's stricter than the spec's bare
22
25
  * "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.
26
+ * compose cleanly with a local name without producing an ambiguous or
27
+ * surprising directive name. Underscore is the composition separator
28
+ * (`ana_timeline`) and is deliberately NOT accepted inside the namespace or
29
+ * local-name segments themselves, which is exactly what makes the composed
30
+ * name's single underscore mark the boundary unambiguously.
28
31
  */
29
32
  /** The reserved bundle directory names a pack namespace must never collide with (spec.md §1, §8). */
30
33
  export const RESERVED_NAMESPACE_SEGMENTS = new Set([
@@ -116,15 +119,30 @@ export function validateLocalComponentName(name) {
116
119
  }
117
120
  /**
118
121
  * Composes the directive name an author types to reference a pack's
119
- * component, e.g. `composeDirectiveName('ana', 'timeline')` -> `ana-timeline`
122
+ * component, e.g. `composeDirectiveName('ana', 'timeline')` -> `ana_timeline`
120
123
  * (docs/packs.md). Validates both inputs first, then the composed result
121
124
  * (defense in depth: the composed string must still contain no `:` and must
122
125
  * still be a legal directive-name shape).
123
126
  *
124
- * `separator` defaults to `-`; `_` is also accepted per spec.md §1's "joins
125
- * the namespace and name with `-` or `_`".
127
+ * The separator is `_`, and there is deliberately no way to ask for another
128
+ * one (issue #19, user-settled 2026-08-30). It used to be `-`, which is also
129
+ * the separator INSIDE a lowercase-kebab pack or local name, so two
130
+ * different packs could compose the same directive name: pack `long` plus
131
+ * component `pack-name-some-component` and pack `long-pack-name` plus
132
+ * component `some-component` both produced `long-pack-name-some-component`,
133
+ * and one silently shadowed the other. `SEGMENT_RE` bans `_` inside either
134
+ * segment, so an underscore join gives every composed name exactly one
135
+ * underscore, at the pack/component boundary. That makes the split
136
+ * bijective and this whole collision class impossible to construct rather
137
+ * than merely detected.
138
+ *
139
+ * Accepting both forms would have kept the ambiguity alive, so only this one
140
+ * exists. Notes and packs written against the old `-` composition stop
141
+ * resolving and render the labeled unknown-component fallback; that breakage
142
+ * was weighed and accepted pre-1.0 rather than carrying a migration shim.
126
143
  */
127
- export function composeDirectiveName(packName, localName, separator = '-') {
144
+ export function composeDirectiveName(packName, localName) {
145
+ const separator = '_';
128
146
  const packResult = validatePackName(packName);
129
147
  if (!packResult.ok)
130
148
  return packResult;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markii/pack",
3
- "version": "0.7.2",
3
+ "version": "0.8.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",
@@ -11,11 +11,11 @@
11
11
  ],
12
12
  "license": "MIT",
13
13
  "author": "sadigaxund",
14
- "homepage": "https://github.com/sadigaxund/markii#readme",
15
- "bugs": "https://github.com/sadigaxund/markii/issues",
14
+ "homepage": "https://github.com/markii-org/markii#readme",
15
+ "bugs": "https://github.com/markii-org/markii/issues",
16
16
  "repository": {
17
17
  "type": "git",
18
- "url": "git+https://github.com/sadigaxund/markii.git",
18
+ "url": "git+https://github.com/markii-org/markii.git",
19
19
  "directory": "packages/markii-pack"
20
20
  },
21
21
  "type": "module",