@topy-ai/maggie 0.7.8 → 0.7.9
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
|
@@ -218,8 +218,8 @@ artifact schemas.
|
|
|
218
218
|
Recommended upgrade sequence for the current release:
|
|
219
219
|
|
|
220
220
|
```bash
|
|
221
|
-
npx @topy-ai/maggie@0.7.
|
|
222
|
-
npx @topy-ai/maggie@0.7.
|
|
221
|
+
npx @topy-ai/maggie@0.7.9 update --project . --force
|
|
222
|
+
npx @topy-ai/maggie@0.7.9 cleanup --project .
|
|
223
223
|
```
|
|
224
224
|
|
|
225
225
|
Maintainers should pass npm credentials through the repository helper, never
|
|
@@ -229,7 +229,9 @@ as a command-line argument:
|
|
|
229
229
|
node scripts/publish-npm.mjs --maggie-env-file ../.env
|
|
230
230
|
```
|
|
231
231
|
|
|
232
|
-
The 0.7.
|
|
232
|
+
The 0.7.9 workflow adds nested section-field contracts, renderer-backed
|
|
233
|
+
examples, stable-ID preservation during migration, and disjoint page
|
|
234
|
+
inventory guidance. It retains the shared Google integrations runbook and
|
|
233
235
|
fail-closed provider capability matrix, alongside the installable MaggieDash admin distribution and
|
|
234
236
|
audited CMS operations (`cms revisions`,
|
|
235
237
|
`trash`, `restore`, `schedule`, `publish-due`, `duplicate`, `redirect`, and
|
|
@@ -25,6 +25,15 @@ frontend framework.
|
|
|
25
25
|
- [`translation-cache-policy-v1.json`](translation-cache-policy-v1.json):
|
|
26
26
|
restart-after-out-of-band-write evidence.
|
|
27
27
|
|
|
28
|
+
The section registry is an adapter input rather than a fixed six-band list.
|
|
29
|
+
Each registry entry must describe its purpose and limits, include a
|
|
30
|
+
renderer-compatible `example`, and describe object-shaped repeated fields
|
|
31
|
+
under `repeats.of`. Host-specific bands are valid when the host supplies the
|
|
32
|
+
matching renderer and a preview at its real supported breakpoints. Template
|
|
33
|
+
inventory adapters must count published pages once using disjoint page
|
|
34
|
+
classes; child records such as service price options and saved arrangements
|
|
35
|
+
must not be reported as pages.
|
|
36
|
+
|
|
28
37
|
MaggieDash owns these contracts. Provider adapters may add namespaced metadata,
|
|
29
38
|
but they may not change the required identity, status, provenance, or approval
|
|
30
39
|
fields. Unknown fields must be preserved or reported as unsupported during
|
|
@@ -180,9 +180,26 @@ maggie dash sections keys --registry templates/maggiedash/section-registry.json
|
|
|
180
180
|
```
|
|
181
181
|
|
|
182
182
|
The catalogue declares purpose, usage, placement, repeatability and layout
|
|
183
|
-
limits
|
|
183
|
+
limits, renderer-owned examples, and the shape of repeated entries. A repeat
|
|
184
|
+
may contain an object (`title`, `body`, `href`, and so on), not just a count;
|
|
185
|
+
the nested limits are displayed to the planner and checked by `sections
|
|
186
|
+
validate`. Over-limit copy is an editor note; unknown types fail validation.
|
|
187
|
+
The starter registry is provider-neutral and extensible: a host may add its
|
|
188
|
+
own renderer-backed bands, but every added band must have a real-renderer
|
|
189
|
+
preview or an explicitly labelled example fallback at the supported
|
|
190
|
+
breakpoints. Do not describe the vocabulary as a fixed number of bands.
|
|
191
|
+
|
|
184
192
|
Translation keys use stable section IDs, with an array-index fallback only
|
|
185
|
-
until the ordered migration is complete.
|
|
193
|
+
until the ordered migration is complete. Migration preserves unique IDs that
|
|
194
|
+
already belong to the source section list; it generates a new ID only for a
|
|
195
|
+
missing, duplicate, or target-conflicting ID. Never rebuild IDs from array
|
|
196
|
+
position or overwrite existing translation keys.
|
|
197
|
+
|
|
198
|
+
Keep data-owned values out of page copy. For example, a pricing band should
|
|
199
|
+
store a service identifier or slug and let the live service/booking adapter
|
|
200
|
+
render current options and prices. Inventory reports must classify each
|
|
201
|
+
published page once into disjoint groups; price options, arrangements, and
|
|
202
|
+
other child records are counts, not pages.
|
|
186
203
|
|
|
187
204
|
After any script or direct adapter write to translation data, invalidate the
|
|
188
205
|
running process before verification. Record the restart and run the rendering
|
|
@@ -1,12 +1,84 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": "maggiedash-section-registry.v1",
|
|
3
|
-
"description": "The
|
|
3
|
+
"description": "The provider-neutral starter vocabulary used by page planners and editors. Hosts may extend it with renderer-backed section types.",
|
|
4
4
|
"sections": [
|
|
5
|
-
{
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
{
|
|
6
|
+
"type": "hero",
|
|
7
|
+
"purpose": "Says what the page is about and offers the one important action.",
|
|
8
|
+
"usage": "First on the page; once.",
|
|
9
|
+
"position": "opening",
|
|
10
|
+
"repeatable": false,
|
|
11
|
+
"fields": [
|
|
12
|
+
{"name": "title", "usage": "Plain words a visitor would search for.", "limit": 70, "required": true},
|
|
13
|
+
{"name": "intro", "usage": "Who it is for and what it does.", "limit": 220},
|
|
14
|
+
{"name": "image", "usage": "The hero image rendered beside or behind the copy.", "limit": 500},
|
|
15
|
+
{"name": "imageAlt", "usage": "What the hero image shows for a reader who cannot see it.", "limit": 160},
|
|
16
|
+
{"name": "ctaLabel", "usage": "The action as a verb.", "limit": 30}
|
|
17
|
+
],
|
|
18
|
+
"example": {"type": "hero", "title": "A clearer way to plan your next step", "intro": "Useful guidance for people who want to move forward with confidence.", "image": "https://images.unsplash.com/photo-1497366754035-f200968a6e72", "imageAlt": "A bright workspace with a table and plants", "ctaLabel": "Get started"}
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"type": "prose",
|
|
22
|
+
"purpose": "Explains a topic at length, optionally beside an image.",
|
|
23
|
+
"usage": "Use in the body for explanation.",
|
|
24
|
+
"position": "body",
|
|
25
|
+
"repeatable": true,
|
|
26
|
+
"fields": [
|
|
27
|
+
{"name": "heading", "usage": "The argument in a sentence.", "limit": 90},
|
|
28
|
+
{"name": "paragraphs", "usage": "Standalone paragraphs.", "limit": 0, "repeats": {"min": 1, "max": 4, "of": [{"name": "paragraph", "usage": "One paragraph of plain prose.", "limit": 400}]}},
|
|
29
|
+
{"name": "image", "usage": "An optional supporting image.", "limit": 500},
|
|
30
|
+
{"name": "imageAlt", "usage": "What the supporting image shows.", "limit": 160}
|
|
31
|
+
],
|
|
32
|
+
"example": {"type": "prose", "heading": "Make the important part easier to understand", "paragraphs": ["Start with the decision your reader is trying to make.", "Then give them the context, evidence and next step in that order."]}
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"type": "cards",
|
|
36
|
+
"purpose": "Presents parallel points side by side.",
|
|
37
|
+
"usage": "Use for steps or genuinely parallel points.",
|
|
38
|
+
"position": "body",
|
|
39
|
+
"repeatable": true,
|
|
40
|
+
"fields": [
|
|
41
|
+
{"name": "heading", "usage": "What the cards share.", "limit": 90},
|
|
42
|
+
{"name": "items", "usage": "The parallel points.", "limit": 0, "repeats": {"min": 3, "max": 3, "of": [{"name": "title", "usage": "The point in a few words.", "limit": 60, "required": true}, {"name": "body", "usage": "One or two sentences.", "limit": 220}]}}
|
|
43
|
+
],
|
|
44
|
+
"example": {"type": "cards", "heading": "A simple path from question to action", "items": [{"title": "Understand", "body": "See the essential context in one place."}, {"title": "Choose", "body": "Compare the options that fit your situation."}, {"title": "Act", "body": "Take the next step with a clear expectation."}]}
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"type": "links",
|
|
48
|
+
"purpose": "Sends the reader to related pages.",
|
|
49
|
+
"usage": "Near the end after the page has done its job.",
|
|
50
|
+
"position": "closing",
|
|
51
|
+
"repeatable": true,
|
|
52
|
+
"fields": [
|
|
53
|
+
{"name": "heading", "usage": "What the links have in common.", "limit": 90},
|
|
54
|
+
{"name": "items", "usage": "Real related pages on this site.", "limit": 0, "repeats": {"min": 2, "max": 6, "of": [{"name": "label", "usage": "The destination page name.", "limit": 60, "required": true}, {"name": "href", "usage": "A real path on this site.", "limit": 200, "required": true}, {"name": "body", "usage": "One sentence on what is there.", "limit": 160}]}}
|
|
55
|
+
],
|
|
56
|
+
"example": {"type": "links", "heading": "Keep exploring", "items": [{"label": "How it works", "href": "/how-it-works/", "body": "See the process from start to finish."}, {"label": "Frequently asked questions", "href": "/faq/", "body": "Find concise answers to common questions."}]}
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"type": "faq",
|
|
60
|
+
"purpose": "Answers questions a visitor would otherwise ask.",
|
|
61
|
+
"usage": "Once near the end; use real visitor questions.",
|
|
62
|
+
"position": "closing",
|
|
63
|
+
"repeatable": false,
|
|
64
|
+
"fields": [
|
|
65
|
+
{"name": "heading", "usage": "The question topic.", "limit": 90},
|
|
66
|
+
{"name": "items", "usage": "Direct questions and answers.", "limit": 0, "repeats": {"min": 2, "max": 8, "of": [{"name": "question", "usage": "A question a real visitor asks.", "limit": 140, "required": true}, {"name": "answer", "usage": "A direct, useful answer.", "limit": 600, "required": true}]}}
|
|
67
|
+
],
|
|
68
|
+
"example": {"type": "faq", "heading": "Questions, answered", "items": [{"question": "What happens next?", "answer": "You will see the relevant options and can choose the next step."}, {"question": "Can I ask for help?", "answer": "Yes. Use the contact route and include the decision you are making."}]}
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"type": "cta",
|
|
72
|
+
"purpose": "Makes the closing ask.",
|
|
73
|
+
"usage": "Last on the page; once.",
|
|
74
|
+
"position": "closing",
|
|
75
|
+
"repeatable": false,
|
|
76
|
+
"fields": [
|
|
77
|
+
{"name": "heading", "usage": "The invitation in a sentence.", "limit": 90, "required": true},
|
|
78
|
+
{"name": "body", "usage": "What happens next.", "limit": 220},
|
|
79
|
+
{"name": "label", "usage": "The action as a verb.", "limit": 30, "required": true}
|
|
80
|
+
],
|
|
81
|
+
"example": {"type": "cta", "heading": "Ready to take the next step?", "body": "Start with the option that best matches your goal.", "label": "Get started"}
|
|
82
|
+
}
|
|
11
83
|
]
|
|
12
84
|
}
|
|
@@ -14,6 +14,35 @@ SCHEMA = "maggiedash-section-registry.v1"
|
|
|
14
14
|
IDENTITY_SCHEMA = "maggiedash-section-identity.v1"
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
def _validate_field(field: object, at: str, errors: list[str], *, nested: bool = False) -> None:
|
|
18
|
+
if not isinstance(field, dict) or not field.get("name"):
|
|
19
|
+
errors.append(f"{at} needs a name")
|
|
20
|
+
return
|
|
21
|
+
if not isinstance(field.get("limit"), int) or field["limit"] < 0:
|
|
22
|
+
errors.append(f"{at}.limit must be a non-negative integer")
|
|
23
|
+
if not str(field.get("usage") or "").strip():
|
|
24
|
+
errors.append(f"{at}.usage is required")
|
|
25
|
+
repeats = field.get("repeats")
|
|
26
|
+
if repeats is None:
|
|
27
|
+
return
|
|
28
|
+
if not isinstance(repeats, dict) or not isinstance(repeats.get("min"), int) or not isinstance(repeats.get("max"), int) or repeats["min"] < 0 or repeats["min"] > repeats["max"]:
|
|
29
|
+
errors.append(f"{at}.repeats must declare valid min/max")
|
|
30
|
+
return
|
|
31
|
+
item_fields = repeats.get("of")
|
|
32
|
+
if not isinstance(item_fields, list) or not item_fields:
|
|
33
|
+
errors.append(f"{at}.repeats.of must be a non-empty field list")
|
|
34
|
+
return
|
|
35
|
+
nested_names: set[str] = set()
|
|
36
|
+
for item_index, item_field in enumerate(item_fields):
|
|
37
|
+
item_at = f"{at}.repeats.of[{item_index}]"
|
|
38
|
+
_validate_field(item_field, item_at, errors, nested=True)
|
|
39
|
+
if isinstance(item_field, dict) and item_field.get("name"):
|
|
40
|
+
name = str(item_field["name"])
|
|
41
|
+
if name in nested_names:
|
|
42
|
+
errors.append(f"duplicate repeated field: {name}")
|
|
43
|
+
nested_names.add(name)
|
|
44
|
+
|
|
45
|
+
|
|
17
46
|
def validate_registry(value: object) -> dict[str, Any]:
|
|
18
47
|
errors: list[str] = []
|
|
19
48
|
if not isinstance(value, dict):
|
|
@@ -57,13 +86,10 @@ def validate_registry(value: object) -> dict[str, Any]:
|
|
|
57
86
|
if name in field_names:
|
|
58
87
|
errors.append(f"duplicate field: {section_type}.{name}")
|
|
59
88
|
field_names.add(name)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
repeats = field.get("repeats")
|
|
65
|
-
if repeats is not None and (not isinstance(repeats, dict) or not isinstance(repeats.get("min"), int) or not isinstance(repeats.get("max"), int) or repeats["min"] < 0 or repeats["min"] > repeats["max"]):
|
|
66
|
-
errors.append(f"{field_at}.repeats must declare valid min/max")
|
|
89
|
+
_validate_field(field, field_at, errors)
|
|
90
|
+
example = section.get("example")
|
|
91
|
+
if not isinstance(example, dict) or example.get("type") != section_type:
|
|
92
|
+
errors.append(f"{at}.example must be an object with type {section_type}")
|
|
67
93
|
return {"schemaVersion": SCHEMA, "passed": not errors, "errors": errors, "sectionTypes": sorted(seen)}
|
|
68
94
|
|
|
69
95
|
|
|
@@ -74,11 +100,39 @@ def catalogue(registry: dict[str, Any]) -> str:
|
|
|
74
100
|
lines.append(f"{section['type']} — {section['purpose']} | when: {section['usage']} | place: {section['position']} | {'repeatable' if section['repeatable'] else 'once'}")
|
|
75
101
|
for field in section.get("fields", []):
|
|
76
102
|
repeat = field.get("repeats")
|
|
77
|
-
suffix =
|
|
103
|
+
suffix = ""
|
|
104
|
+
if isinstance(repeat, dict):
|
|
105
|
+
count = repeat["min"] if repeat["min"] == repeat["max"] else f"{repeat['min']}-{repeat['max']}"
|
|
106
|
+
nested = ", ".join(f"{item['name']} ≤{item['limit']}" for item in repeat.get("of", []) if isinstance(item, dict))
|
|
107
|
+
suffix = f" [{count} entries" + (f" of {{{nested}}}" if nested else "") + "]"
|
|
78
108
|
lines.append(f" - {field['name']} ≤{field['limit']} chars{suffix}: {field['usage']}")
|
|
79
109
|
return "\n".join(lines)
|
|
80
110
|
|
|
81
111
|
|
|
112
|
+
def _copy_notes(value: object, fields: list[dict[str, Any]], path: str, notes: list[dict[str, Any]]) -> None:
|
|
113
|
+
"""Check a field value, including object-shaped repeated entries."""
|
|
114
|
+
for field in fields:
|
|
115
|
+
name = str(field.get("name"))
|
|
116
|
+
child = value.get(name) if isinstance(value, dict) else None
|
|
117
|
+
limit = int(field.get("limit", 0))
|
|
118
|
+
if isinstance(child, str) and limit and len(child) > limit:
|
|
119
|
+
notes.append({"at": f"{path}.{name}", "message": f"{len(child)} characters; layout limit is about {limit}"})
|
|
120
|
+
repeats = field.get("repeats")
|
|
121
|
+
if not isinstance(repeats, dict) or not isinstance(child, list):
|
|
122
|
+
continue
|
|
123
|
+
if not repeats["min"] <= len(child) <= repeats["max"]:
|
|
124
|
+
notes.append({"at": f"{path}.{name}", "message": f"{len(child)} entries; layout expects {repeats['min']}-{repeats['max']}"})
|
|
125
|
+
item_fields = [item for item in repeats.get("of", []) if isinstance(item, dict)]
|
|
126
|
+
for item_index, item in enumerate(child):
|
|
127
|
+
item_path = f"{path}.{name}[{item_index}]"
|
|
128
|
+
if isinstance(item, dict):
|
|
129
|
+
_copy_notes(item, item_fields, item_path, notes)
|
|
130
|
+
elif len(item_fields) == 1 and isinstance(item, str):
|
|
131
|
+
item_limit = int(item_fields[0].get("limit", 0))
|
|
132
|
+
if item_limit and len(item) > item_limit:
|
|
133
|
+
notes.append({"at": item_path, "message": f"{len(item)} characters; layout limit is about {item_limit}"})
|
|
134
|
+
|
|
135
|
+
|
|
82
136
|
def copy_notes(registry: dict[str, Any], sections: object) -> dict[str, Any]:
|
|
83
137
|
"""Return advisory copy/shape notes; over-limit copy is not rejected."""
|
|
84
138
|
errors: list[str] = []
|
|
@@ -95,15 +149,7 @@ def copy_notes(registry: dict[str, Any], sections: object) -> dict[str, Any]:
|
|
|
95
149
|
if not spec:
|
|
96
150
|
errors.append(f"unknown section type: {section_type}")
|
|
97
151
|
continue
|
|
98
|
-
for field in spec.get("fields", [])
|
|
99
|
-
name = str(field.get("name"))
|
|
100
|
-
value = section.get(name)
|
|
101
|
-
limit = int(field.get("limit", 0))
|
|
102
|
-
if isinstance(value, str) and limit and len(value) > limit:
|
|
103
|
-
notes.append({"at": f"sections[{index}].{name}", "message": f"{len(value)} characters; layout limit is about {limit}"})
|
|
104
|
-
repeats = field.get("repeats")
|
|
105
|
-
if isinstance(repeats, dict) and isinstance(value, list) and not repeats["min"] <= len(value) <= repeats["max"]:
|
|
106
|
-
notes.append({"at": f"sections[{index}].{name}", "message": f"{len(value)} entries; layout expects {repeats['min']}-{repeats['max']}"})
|
|
152
|
+
_copy_notes(section, [field for field in spec.get("fields", []) if isinstance(field, dict)], f"sections[{index}]", notes)
|
|
107
153
|
return {"passed": not errors, "errors": errors, "notes": notes}
|
|
108
154
|
|
|
109
155
|
|
|
@@ -117,12 +163,16 @@ def new_section_id(existing: Iterable[str] = ()) -> str:
|
|
|
117
163
|
|
|
118
164
|
def ensure_section_ids(sections: list[dict[str, Any]], existing: Iterable[str] = ()) -> list[dict[str, Any]]:
|
|
119
165
|
result = copy.deepcopy(sections)
|
|
120
|
-
|
|
121
|
-
taken = set(
|
|
166
|
+
reserved = {str(item) for item in existing if str(item)}
|
|
167
|
+
taken: set[str] = set()
|
|
122
168
|
for section in result:
|
|
123
169
|
current = str(section.get("id") or "")
|
|
124
|
-
|
|
125
|
-
|
|
170
|
+
# Existing IDs belong to the source section list and must survive a
|
|
171
|
+
# migration. Only absent, duplicated, or target-conflicting IDs get a
|
|
172
|
+
# replacement. The old implementation put current IDs in `taken`
|
|
173
|
+
# before inspecting them and consequently rewrote every section.
|
|
174
|
+
if not current or current in taken or current in reserved:
|
|
175
|
+
section["id"] = new_section_id(taken | reserved)
|
|
126
176
|
taken.add(str(section["id"]))
|
|
127
177
|
return result
|
|
128
178
|
|