multisite-cms-mcp 1.0.5 → 1.0.7
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/dist/tools/get-conversion-guide.d.ts.map +1 -1
- package/dist/tools/get-conversion-guide.js +42 -7
- package/dist/tools/get-example.d.ts +1 -1
- package/dist/tools/get-example.d.ts.map +1 -1
- package/dist/tools/get-example.js +105 -0
- package/dist/tools/get-schema.d.ts.map +1 -1
- package/dist/tools/get-schema.js +86 -0
- package/dist/tools/validate-template.d.ts.map +1 -1
- package/dist/tools/validate-template.js +28 -0
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"get-conversion-guide.d.ts","sourceRoot":"","sources":["../../src/tools/get-conversion-guide.ts"],"names":[],"mappings":"AAAA,KAAK,OAAO,GAAG,MAAM,GAAG,UAAU,GAAG,WAAW,GAAG,UAAU,GAAG,WAAW,GAAG,QAAQ,GAAG,OAAO,GAAG,QAAQ,GAAG,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"get-conversion-guide.d.ts","sourceRoot":"","sources":["../../src/tools/get-conversion-guide.ts"],"names":[],"mappings":"AAAA,KAAK,OAAO,GAAG,MAAM,GAAG,UAAU,GAAG,WAAW,GAAG,UAAU,GAAG,WAAW,GAAG,QAAQ,GAAG,OAAO,GAAG,QAAQ,GAAG,WAAW,CAAC;AAugB1H;;GAEG;AACH,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,CAAC,CAkD1E"}
|
|
@@ -287,6 +287,38 @@ Inside {{#each}}:
|
|
|
287
287
|
- \`{{@last}}\` - true for last item
|
|
288
288
|
- \`{{@index}}\` - zero-based index
|
|
289
289
|
|
|
290
|
+
## Parent Context (\`../\`)
|
|
291
|
+
Inside loops, access the parent scope:
|
|
292
|
+
\`\`\`html
|
|
293
|
+
{{#each blogs}}
|
|
294
|
+
{{#if (eq author.name ../name)}}
|
|
295
|
+
<!-- Only show posts by current author -->
|
|
296
|
+
<h3>{{name}}</h3>
|
|
297
|
+
{{/if}}
|
|
298
|
+
{{/each}}
|
|
299
|
+
\`\`\`
|
|
300
|
+
|
|
301
|
+
- \`../name\` - Parent item's name field
|
|
302
|
+
- \`../slug\` - Parent item's slug
|
|
303
|
+
- \`../fieldName\` - Any field from parent scope
|
|
304
|
+
|
|
305
|
+
**Use cases:**
|
|
306
|
+
- Author pages: filter posts by current author
|
|
307
|
+
- Category pages: filter items by current category
|
|
308
|
+
- Related items: match based on current page
|
|
309
|
+
|
|
310
|
+
## Equality Comparisons
|
|
311
|
+
Compare two values:
|
|
312
|
+
\`\`\`html
|
|
313
|
+
{{#if (eq author.name ../name)}}
|
|
314
|
+
<!-- True when fields match -->
|
|
315
|
+
{{/if}}
|
|
316
|
+
|
|
317
|
+
{{#eq status "published"}}
|
|
318
|
+
<!-- Compare to literal string -->
|
|
319
|
+
{{/eq}}
|
|
320
|
+
\`\`\`
|
|
321
|
+
|
|
290
322
|
## Collection Fields
|
|
291
323
|
|
|
292
324
|
**Blog Posts:**
|
|
@@ -331,33 +363,34 @@ Forms are automatically captured by the CMS.
|
|
|
331
363
|
|
|
332
364
|
## Form Handler Script
|
|
333
365
|
|
|
334
|
-
Add to your JavaScript:
|
|
366
|
+
Add to your JavaScript (typically /public/js/main.js):
|
|
335
367
|
|
|
336
368
|
\`\`\`javascript
|
|
337
369
|
document.querySelectorAll('form[data-form-name]').forEach(form => {
|
|
338
370
|
form.addEventListener('submit', async (e) => {
|
|
339
371
|
e.preventDefault();
|
|
340
372
|
|
|
373
|
+
const formName = form.dataset.formName || 'general';
|
|
341
374
|
const formData = new FormData(form);
|
|
342
375
|
const data = Object.fromEntries(formData);
|
|
343
376
|
|
|
344
|
-
|
|
377
|
+
// IMPORTANT: Endpoint is /_forms/{formName}
|
|
378
|
+
const response = await fetch('/_forms/' + formName, {
|
|
345
379
|
method: 'POST',
|
|
346
380
|
headers: { 'Content-Type': 'application/json' },
|
|
347
|
-
body: JSON.stringify(
|
|
348
|
-
formName: form.dataset.formName,
|
|
349
|
-
data: data
|
|
350
|
-
})
|
|
381
|
+
body: JSON.stringify(data)
|
|
351
382
|
});
|
|
352
383
|
|
|
353
384
|
if (response.ok) {
|
|
354
385
|
form.reset();
|
|
355
|
-
alert('Thank you!');
|
|
386
|
+
alert(form.dataset.successMessage || 'Thank you!');
|
|
356
387
|
}
|
|
357
388
|
});
|
|
358
389
|
});
|
|
359
390
|
\`\`\`
|
|
360
391
|
|
|
392
|
+
**CRITICAL:** The form endpoint is \`/_forms/{formName}\` - NOT \`/api/forms/submit\`
|
|
393
|
+
|
|
361
394
|
## Naming Conventions
|
|
362
395
|
|
|
363
396
|
- Contact form → \`contact\`
|
|
@@ -444,7 +477,9 @@ Keep external URLs unchanged:
|
|
|
444
477
|
- [ ] Templates include header/footer
|
|
445
478
|
- [ ] {{#each}} loops have {{/each}}
|
|
446
479
|
- [ ] {{#if}} conditions have {{/if}}
|
|
480
|
+
- [ ] {{#eq}} comparisons have {{/eq}}
|
|
447
481
|
- [ ] Rich text uses {{{triple braces}}}
|
|
482
|
+
- [ ] Parent refs (../) only inside loops
|
|
448
483
|
- [ ] Correct field names used
|
|
449
484
|
|
|
450
485
|
## ✓ Field Names
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
type ExampleType = 'manifest_basic' | 'manifest_custom_paths' | 'blog_index_template' | 'blog_post_template' | 'team_template' | 'downloads_template' | 'authors_template' | 'author_detail_template' | 'custom_collection_template' | 'form_handling' | 'asset_paths' | 'data_edit_keys' | 'each_loop' | 'conditional_if' | 'nested_fields' | 'featured_posts';
|
|
1
|
+
type ExampleType = 'manifest_basic' | 'manifest_custom_paths' | 'blog_index_template' | 'blog_post_template' | 'team_template' | 'downloads_template' | 'authors_template' | 'author_detail_template' | 'custom_collection_template' | 'form_handling' | 'asset_paths' | 'data_edit_keys' | 'each_loop' | 'conditional_if' | 'nested_fields' | 'featured_posts' | 'parent_context' | 'equality_comparison';
|
|
2
2
|
/**
|
|
3
3
|
* Returns example code for a specific pattern
|
|
4
4
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"get-example.d.ts","sourceRoot":"","sources":["../../src/tools/get-example.ts"],"names":[],"mappings":"AAAA,KAAK,WAAW,GACZ,gBAAgB,GAChB,uBAAuB,GACvB,qBAAqB,GACrB,oBAAoB,GACpB,eAAe,GACf,oBAAoB,GACpB,kBAAkB,GAClB,wBAAwB,GACxB,4BAA4B,GAC5B,eAAe,GACf,aAAa,GACb,gBAAgB,GAChB,WAAW,GACX,gBAAgB,GAChB,eAAe,GACf,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"get-example.d.ts","sourceRoot":"","sources":["../../src/tools/get-example.ts"],"names":[],"mappings":"AAAA,KAAK,WAAW,GACZ,gBAAgB,GAChB,uBAAuB,GACvB,qBAAqB,GACrB,oBAAoB,GACpB,eAAe,GACf,oBAAoB,GACpB,kBAAkB,GAClB,wBAAwB,GACxB,4BAA4B,GAC5B,eAAe,GACf,aAAa,GACb,gBAAgB,GAChB,WAAW,GACX,gBAAgB,GAChB,eAAe,GACf,gBAAgB,GAChB,gBAAgB,GAChB,qBAAqB,CAAC;AAqxB1B;;GAEG;AACH,wBAAsB,UAAU,CAAC,WAAW,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC,CAE1E"}
|
|
@@ -664,6 +664,111 @@ Make text editable in the CMS visual editor:
|
|
|
664
664
|
</section>
|
|
665
665
|
{{/each}}
|
|
666
666
|
\`\`\``,
|
|
667
|
+
parent_context: `# Parent Context References (\`../\`)
|
|
668
|
+
|
|
669
|
+
Inside loops, access the **parent scope** (the page's current item) using \`../\`:
|
|
670
|
+
|
|
671
|
+
**Use Case: Author Detail Page - Show Only This Author's Posts**
|
|
672
|
+
\`\`\`html
|
|
673
|
+
<article class="author-detail">
|
|
674
|
+
<h1>{{name}}</h1>
|
|
675
|
+
<p class="bio">{{{bio}}}</p>
|
|
676
|
+
|
|
677
|
+
<section class="author-articles">
|
|
678
|
+
<h2>Posts by {{name}}</h2>
|
|
679
|
+
|
|
680
|
+
{{#each blogs}}
|
|
681
|
+
{{#if (eq author.name ../name)}}
|
|
682
|
+
<article class="post-card">
|
|
683
|
+
<h3><a href="{{url}}">{{name}}</a></h3>
|
|
684
|
+
<p>{{postSummary}}</p>
|
|
685
|
+
<time>{{publishedAt}}</time>
|
|
686
|
+
</article>
|
|
687
|
+
{{/if}}
|
|
688
|
+
{{/each}}
|
|
689
|
+
</section>
|
|
690
|
+
</article>
|
|
691
|
+
\`\`\`
|
|
692
|
+
|
|
693
|
+
**How It Works:**
|
|
694
|
+
- Inside \`{{#each blogs}}\`, the context is each blog post
|
|
695
|
+
- \`author.name\` = the blog post's author
|
|
696
|
+
- \`../name\` = the parent context (the author being displayed on the page)
|
|
697
|
+
- Only posts where author.name matches ../name are shown
|
|
698
|
+
|
|
699
|
+
**Use Case: Category Page - Highlight Current Category**
|
|
700
|
+
\`\`\`html
|
|
701
|
+
<nav class="category-nav">
|
|
702
|
+
{{#each categories}}
|
|
703
|
+
<a href="{{url}}" class="{{#if (eq slug ../slug)}}active{{/if}}">
|
|
704
|
+
{{name}}
|
|
705
|
+
</a>
|
|
706
|
+
{{/each}}
|
|
707
|
+
</nav>
|
|
708
|
+
|
|
709
|
+
<div class="category-content">
|
|
710
|
+
<h1>{{name}}</h1>
|
|
711
|
+
|
|
712
|
+
{{#each items}}
|
|
713
|
+
{{#if (eq category ../slug)}}
|
|
714
|
+
<div class="item">{{name}}</div>
|
|
715
|
+
{{/if}}
|
|
716
|
+
{{/each}}
|
|
717
|
+
</div>
|
|
718
|
+
\`\`\`
|
|
719
|
+
|
|
720
|
+
**Available Parent Fields:**
|
|
721
|
+
- \`../name\` - Parent item's name
|
|
722
|
+
- \`../slug\` - Parent item's slug
|
|
723
|
+
- \`../fieldName\` - Any field from the parent item
|
|
724
|
+
- \`../nested.field\` - Nested field access in parent`,
|
|
725
|
+
equality_comparison: `# Equality Comparisons
|
|
726
|
+
|
|
727
|
+
Compare two values using \`(eq field1 field2)\` helper:
|
|
728
|
+
|
|
729
|
+
**Compare Two Fields:**
|
|
730
|
+
\`\`\`html
|
|
731
|
+
{{#if (eq author.slug ../slug)}}
|
|
732
|
+
<span class="current-author-badge">✓ Your Post</span>
|
|
733
|
+
{{/if}}
|
|
734
|
+
|
|
735
|
+
{{#if (eq category selectedCategory)}}
|
|
736
|
+
<div class="active">{{name}}</div>
|
|
737
|
+
{{/if}}
|
|
738
|
+
\`\`\`
|
|
739
|
+
|
|
740
|
+
**Compare Field to Literal String (use {{#eq}}):**
|
|
741
|
+
\`\`\`html
|
|
742
|
+
{{#eq status "published"}}
|
|
743
|
+
<span class="badge badge-success">Published</span>
|
|
744
|
+
{{/eq}}
|
|
745
|
+
|
|
746
|
+
{{#eq type "featured"}}
|
|
747
|
+
<div class="featured-highlight">⭐ {{name}}</div>
|
|
748
|
+
{{/eq}}
|
|
749
|
+
|
|
750
|
+
{{#eq category "news"}}
|
|
751
|
+
<span class="news-icon">📰</span>
|
|
752
|
+
{{/eq}}
|
|
753
|
+
\`\`\`
|
|
754
|
+
|
|
755
|
+
**Inside Loops with Parent Context:**
|
|
756
|
+
\`\`\`html
|
|
757
|
+
{{#each blogs}}
|
|
758
|
+
<article class="{{#if (eq author.name ../name)}}highlight{{/if}}">
|
|
759
|
+
<h3>{{name}}</h3>
|
|
760
|
+
{{#if (eq author.name ../name)}}
|
|
761
|
+
<span class="yours">You wrote this!</span>
|
|
762
|
+
{{/if}}
|
|
763
|
+
</article>
|
|
764
|
+
{{/each}}
|
|
765
|
+
\`\`\`
|
|
766
|
+
|
|
767
|
+
**Common Use Cases:**
|
|
768
|
+
- Filter items by current category/author/tag
|
|
769
|
+
- Highlight active menu items
|
|
770
|
+
- Show badges for specific statuses
|
|
771
|
+
- Conditional styling based on relationships`,
|
|
667
772
|
};
|
|
668
773
|
/**
|
|
669
774
|
* Returns example code for a specific pattern
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"get-schema.d.ts","sourceRoot":"","sources":["../../src/tools/get-schema.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,wBAAsB,SAAS,IAAI,OAAO,CAAC,MAAM,CAAC,
|
|
1
|
+
{"version":3,"file":"get-schema.d.ts","sourceRoot":"","sources":["../../src/tools/get-schema.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,wBAAsB,SAAS,IAAI,OAAO,CAAC,MAAM,CAAC,CAmOjD"}
|
package/dist/tools/get-schema.js
CHANGED
|
@@ -128,6 +128,40 @@ Inside \`{{#each}}\` blocks:
|
|
|
128
128
|
{{/each}}
|
|
129
129
|
\`\`\`
|
|
130
130
|
|
|
131
|
+
### Parent Context References (\`../\`)
|
|
132
|
+
Inside loops, access the parent scope (page's current item) using \`../\`:
|
|
133
|
+
|
|
134
|
+
\`\`\`html
|
|
135
|
+
<!-- On author detail page, show only posts by THIS author -->
|
|
136
|
+
{{#each blogs}}
|
|
137
|
+
{{#if (eq author.name ../name)}}
|
|
138
|
+
<h3>{{name}}</h3>
|
|
139
|
+
{{/if}}
|
|
140
|
+
{{/each}}
|
|
141
|
+
\`\`\`
|
|
142
|
+
|
|
143
|
+
- \`../name\` - Parent item's name field
|
|
144
|
+
- \`../slug\` - Parent item's slug
|
|
145
|
+
- \`../fieldName\` - Any field from the parent scope
|
|
146
|
+
|
|
147
|
+
**Use cases:**
|
|
148
|
+
- Author pages: filter posts by current author
|
|
149
|
+
- Category pages: filter items by current category
|
|
150
|
+
- Related items: match based on current detail page
|
|
151
|
+
|
|
152
|
+
### Equality Comparisons
|
|
153
|
+
Compare two values in conditionals:
|
|
154
|
+
|
|
155
|
+
\`\`\`html
|
|
156
|
+
{{#if (eq author.name ../name)}}
|
|
157
|
+
<!-- True when fields match -->
|
|
158
|
+
{{/if}}
|
|
159
|
+
|
|
160
|
+
{{#eq status "published"}}
|
|
161
|
+
<!-- Compare field to literal string -->
|
|
162
|
+
{{/eq}}
|
|
163
|
+
\`\`\`
|
|
164
|
+
|
|
131
165
|
### Rich Text (Triple Braces)
|
|
132
166
|
For HTML content that should NOT be escaped:
|
|
133
167
|
\`\`\`html
|
|
@@ -144,5 +178,57 @@ For HTML content that should NOT be escaped:
|
|
|
144
178
|
3. **Always wrap optional fields in {{#if}}** - Check before rendering
|
|
145
179
|
4. **Use {{url}} for links** - Generates correct path based on manifest
|
|
146
180
|
5. **Match field names exactly** - \`{{name}}\` not \`{{title}}\`
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## Form Handling
|
|
185
|
+
|
|
186
|
+
Forms are automatically captured and stored in the CMS.
|
|
187
|
+
|
|
188
|
+
### Form Setup
|
|
189
|
+
1. Add \`data-form-name="xxx"\` attribute to the form
|
|
190
|
+
2. Include the form handler script in your JavaScript
|
|
191
|
+
|
|
192
|
+
\`\`\`html
|
|
193
|
+
<form data-form-name="contact">
|
|
194
|
+
<input type="text" name="firstName" required>
|
|
195
|
+
<input type="email" name="email" required>
|
|
196
|
+
<textarea name="message"></textarea>
|
|
197
|
+
<button type="submit">Send</button>
|
|
198
|
+
</form>
|
|
199
|
+
\`\`\`
|
|
200
|
+
|
|
201
|
+
### Form Handler Script (add to /public/js/main.js)
|
|
202
|
+
\`\`\`javascript
|
|
203
|
+
document.querySelectorAll('form[data-form-name]').forEach(form => {
|
|
204
|
+
form.addEventListener('submit', async (e) => {
|
|
205
|
+
e.preventDefault();
|
|
206
|
+
const formName = form.dataset.formName || 'general';
|
|
207
|
+
const formData = new FormData(form);
|
|
208
|
+
const data = Object.fromEntries(formData);
|
|
209
|
+
|
|
210
|
+
const response = await fetch('/_forms/' + formName, {
|
|
211
|
+
method: 'POST',
|
|
212
|
+
headers: { 'Content-Type': 'application/json' },
|
|
213
|
+
body: JSON.stringify(data)
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
if (response.ok) {
|
|
217
|
+
form.reset();
|
|
218
|
+
alert(form.dataset.successMessage || 'Thank you!');
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
\`\`\`
|
|
223
|
+
|
|
224
|
+
**CRITICAL:** Endpoint is \`/_forms/{formName}\` - NOT \`/api/forms/submit\`
|
|
225
|
+
|
|
226
|
+
### Common Form Names
|
|
227
|
+
- \`contact\` - Contact/inquiry forms
|
|
228
|
+
- \`newsletter\` - Email signups
|
|
229
|
+
- \`quote-request\` - Quote/consultation requests
|
|
230
|
+
- \`appointment\` - Scheduling forms
|
|
231
|
+
|
|
232
|
+
Tenant context is handled automatically by the system.
|
|
147
233
|
`;
|
|
148
234
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"validate-template.d.ts","sourceRoot":"","sources":["../../src/tools/validate-template.ts"],"names":[],"mappings":"AAAA,KAAK,YAAY,GAAG,YAAY,GAAG,WAAW,GAAG,MAAM,GAAG,WAAW,GAAG,eAAe,GAAG,eAAe,GAAG,cAAc,GAAG,eAAe,GAAG,aAAa,CAAC;AA0E7J;;GAEG;AACH,wBAAsB,gBAAgB,CACpC,IAAI,EAAE,MAAM,EACZ,YAAY,EAAE,YAAY,EAC1B,cAAc,CAAC,EAAE,MAAM,GACtB,OAAO,CAAC,MAAM,CAAC,
|
|
1
|
+
{"version":3,"file":"validate-template.d.ts","sourceRoot":"","sources":["../../src/tools/validate-template.ts"],"names":[],"mappings":"AAAA,KAAK,YAAY,GAAG,YAAY,GAAG,WAAW,GAAG,MAAM,GAAG,WAAW,GAAG,eAAe,GAAG,eAAe,GAAG,cAAc,GAAG,eAAe,GAAG,aAAa,CAAC;AA0E7J;;GAEG;AACH,wBAAsB,gBAAgB,CACpC,IAAI,EAAE,MAAM,EACZ,YAAY,EAAE,YAAY,EAC1B,cAAc,CAAC,EAAE,MAAM,GACtB,OAAO,CAAC,MAAM,CAAC,CA+OjB"}
|
|
@@ -208,6 +208,34 @@ async function validateTemplate(html, templateType, collectionSlug) {
|
|
|
208
208
|
if (siteTokens.length > 0) {
|
|
209
209
|
suggestions.push(`- Found ${siteTokens.length} site token(s) like {{site.site_name}} - these come from manifest.json`);
|
|
210
210
|
}
|
|
211
|
+
// Validate parent context references (../)
|
|
212
|
+
const parentRefs = html.match(/\{\{\.\.\/([\w.]+)\}\}/g) || [];
|
|
213
|
+
if (parentRefs.length > 0) {
|
|
214
|
+
// Check if they're used inside a loop
|
|
215
|
+
if (eachLoops.length === 0) {
|
|
216
|
+
warnings.push(`- Found ${parentRefs.length} parent reference(s) like {{../name}} but no {{#each}} loop - these only work inside loops`);
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
suggestions.push(`- Found ${parentRefs.length} parent context reference(s) ({{../fieldName}}) - accesses parent scope in loops`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// Validate equality helper syntax
|
|
223
|
+
const eqHelpers = html.match(/\{\{#if\s+\(eq\s+[^)]+\)\s*\}\}/g) || [];
|
|
224
|
+
if (eqHelpers.length > 0) {
|
|
225
|
+
suggestions.push(`- Found ${eqHelpers.length} equality comparison(s) like {{#if (eq field1 field2)}} - compares two values`);
|
|
226
|
+
// Check if closing {{/if}} exists for each
|
|
227
|
+
for (const helper of eqHelpers) {
|
|
228
|
+
if (!html.includes('{{/if}}')) {
|
|
229
|
+
errors.push(`- Missing {{/if}} to close: ${helper}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// Validate {{#eq}} blocks
|
|
234
|
+
const eqBlocks = html.match(/\{\{#eq\s+[\w.]+\s+"[^"]+"\s*\}\}/g) || [];
|
|
235
|
+
const eqCloses = (html.match(/\{\{\/eq\}\}/g) || []).length;
|
|
236
|
+
if (eqBlocks.length !== eqCloses) {
|
|
237
|
+
errors.push(`- Unbalanced {{#eq}}: ${eqBlocks.length} opens, ${eqCloses} closes`);
|
|
238
|
+
}
|
|
211
239
|
// Build result
|
|
212
240
|
let output = '';
|
|
213
241
|
if (errors.length === 0 && warnings.length === 0) {
|
package/package.json
CHANGED