@happyvertical/smrt-svelte 0.55.10 → 0.55.12
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/AGENTS.md +1 -0
- package/agents/cookbook-shell.md +176 -0
- package/agents/overview-presentation.md +8 -0
- package/agents/overview-surfaces.md +11 -0
- package/agents/screens.md +5 -0
- package/dist/components/cookbook/CookbookApp.svelte +236 -0
- package/dist/components/cookbook/CookbookApp.svelte.d.ts +5 -0
- package/dist/components/cookbook/CookbookApp.svelte.d.ts.map +1 -0
- package/dist/components/cookbook/CookbookSectionOverview.svelte +71 -0
- package/dist/components/cookbook/CookbookSectionOverview.svelte.d.ts +20 -0
- package/dist/components/cookbook/CookbookSectionOverview.svelte.d.ts.map +1 -0
- package/dist/components/cookbook/CookbookSectionPage.svelte +133 -0
- package/dist/components/cookbook/CookbookSectionPage.svelte.d.ts +5 -0
- package/dist/components/cookbook/CookbookSectionPage.svelte.d.ts.map +1 -0
- package/dist/components/cookbook/brand-theme.d.ts +11 -0
- package/dist/components/cookbook/brand-theme.d.ts.map +1 -0
- package/dist/components/cookbook/brand-theme.js +24 -0
- package/dist/components/cookbook/build.d.ts +8 -0
- package/dist/components/cookbook/build.d.ts.map +1 -0
- package/dist/components/cookbook/build.js +277 -0
- package/dist/components/cookbook/catalog.d.ts +37 -0
- package/dist/components/cookbook/catalog.d.ts.map +1 -0
- package/dist/components/cookbook/catalog.js +99 -0
- package/dist/components/cookbook/context.d.ts +23 -0
- package/dist/components/cookbook/context.d.ts.map +1 -0
- package/dist/components/cookbook/context.js +15 -0
- package/dist/components/cookbook/index.d.ts +22 -0
- package/dist/components/cookbook/index.d.ts.map +1 -0
- package/dist/components/cookbook/index.js +20 -0
- package/dist/components/cookbook/page.d.ts +23 -0
- package/dist/components/cookbook/page.d.ts.map +1 -0
- package/dist/components/cookbook/page.js +32 -0
- package/dist/components/cookbook/registry.d.ts +37 -0
- package/dist/components/cookbook/registry.d.ts.map +1 -0
- package/dist/components/cookbook/registry.js +38 -0
- package/dist/components/cookbook/sections.d.ts +60 -0
- package/dist/components/cookbook/sections.d.ts.map +1 -0
- package/dist/components/cookbook/sections.js +88 -0
- package/dist/components/cookbook/server.d.ts +61 -0
- package/dist/components/cookbook/server.d.ts.map +1 -0
- package/dist/components/cookbook/server.js +108 -0
- package/dist/components/cookbook/theme.d.ts +21 -0
- package/dist/components/cookbook/theme.d.ts.map +1 -0
- package/dist/components/cookbook/theme.js +53 -0
- package/dist/components/cookbook/types.d.ts +204 -0
- package/dist/components/cookbook/types.d.ts.map +1 -0
- package/dist/components/cookbook/types.js +1 -0
- package/dist/components/overview/OverviewGrid.svelte +5 -2
- package/dist/components/overview/OverviewGrid.svelte.d.ts.map +1 -1
- package/dist/components/overview/grid-types.d.ts +6 -2
- package/dist/components/overview/grid-types.d.ts.map +1 -1
- package/dist/i18n/strings.overview.d.ts +3 -0
- package/dist/i18n/strings.overview.d.ts.map +1 -1
- package/dist/i18n/strings.overview.js +4 -0
- package/package.json +25 -14
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* A navigation section's own page in a cookbook app (#3749). The sidebar lists
|
|
4
|
+
* only sections, so this is where a section's entries are: an editable
|
|
5
|
+
* overview (the entries as shortcut cards by default) with the shell's layout
|
|
6
|
+
* edit mode driving its grid. A section with no overview shows its menu.
|
|
7
|
+
*
|
|
8
|
+
* Render it from one route (`s/[slug]/+page.svelte`), inside `CookbookApp`.
|
|
9
|
+
*/
|
|
10
|
+
import { useI18n } from '@happyvertical/smrt-ui/i18n';
|
|
11
|
+
import { M } from '../../i18n/strings.overview.js';
|
|
12
|
+
import type { OverviewOverride } from '../overview/types.js';
|
|
13
|
+
import { useShellLayout } from '../workspace/admin-shell/layout-context.js';
|
|
14
|
+
import ShellSectionIcon from '../workspace/admin-shell/ShellSectionIcon.svelte';
|
|
15
|
+
import ShellSectionMenu from '../workspace/admin-shell/ShellSectionMenu.svelte';
|
|
16
|
+
import CookbookSectionOverview from './CookbookSectionOverview.svelte';
|
|
17
|
+
import { getCookbookContext } from './context.js';
|
|
18
|
+
import { DEFAULT_SECTION_ICON, sectionIdFromSlug } from './sections.js';
|
|
19
|
+
import type { CookbookSectionPageProps } from './types.js';
|
|
20
|
+
|
|
21
|
+
let { sectionId, slug, children }: CookbookSectionPageProps = $props();
|
|
22
|
+
|
|
23
|
+
const { t } = useI18n();
|
|
24
|
+
const cookbook = getCookbookContext();
|
|
25
|
+
const layout = useShellLayout();
|
|
26
|
+
|
|
27
|
+
const id = $derived(sectionId ?? (slug ? sectionIdFromSlug(slug) : ''));
|
|
28
|
+
const section = $derived(layout.sections.find((s) => s.id === id));
|
|
29
|
+
const suggested = $derived(cookbook.shell.sections.find((s) => s.id === id));
|
|
30
|
+
const title = $derived(section?.heading?.trim() || suggested?.label || '');
|
|
31
|
+
const icon = $derived(section?.icon ?? suggested?.icon ?? DEFAULT_SECTION_ICON);
|
|
32
|
+
const description = $derived(suggested?.description);
|
|
33
|
+
const page = $derived(cookbook.shell.overviews[id]);
|
|
34
|
+
const registry = $derived(cookbook.registry);
|
|
35
|
+
const store = {
|
|
36
|
+
overview: (key: string) => cookbook.overrideFor(key),
|
|
37
|
+
setOverview: (key: string, override: OverviewOverride | null) =>
|
|
38
|
+
cookbook.setOverride(key, override),
|
|
39
|
+
};
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<svelte:head>
|
|
43
|
+
{#if title}<title>{title}</title>{/if}
|
|
44
|
+
</svelte:head>
|
|
45
|
+
|
|
46
|
+
<main class="cookbook-section">
|
|
47
|
+
{#if section}
|
|
48
|
+
<header>
|
|
49
|
+
<span class="mark"><ShellSectionIcon name={icon} size={28} /></span>
|
|
50
|
+
<div class="heading">
|
|
51
|
+
<h1>{title}</h1>
|
|
52
|
+
{#if description}<p>{description}</p>{/if}
|
|
53
|
+
</div>
|
|
54
|
+
</header>
|
|
55
|
+
{#if page && registry}
|
|
56
|
+
{#key id}
|
|
57
|
+
<CookbookSectionOverview
|
|
58
|
+
definition={page.definition}
|
|
59
|
+
{registry}
|
|
60
|
+
{store}
|
|
61
|
+
entries={cookbook.shell.entries}
|
|
62
|
+
components={cookbook.components}
|
|
63
|
+
models={cookbook.models}
|
|
64
|
+
hostContext={cookbook.widgetContext}
|
|
65
|
+
label={`${title} overview`}
|
|
66
|
+
/>
|
|
67
|
+
{/key}
|
|
68
|
+
{#if layout.editing}
|
|
69
|
+
<section class="entries" aria-labelledby="cookbook-section-entries">
|
|
70
|
+
<h2 id="cookbook-section-entries">{t(M['ui.cookbook.menu_entries'])}</h2>
|
|
71
|
+
<ShellSectionMenu sectionId={id} />
|
|
72
|
+
</section>
|
|
73
|
+
{/if}
|
|
74
|
+
{:else if !page}
|
|
75
|
+
<ShellSectionMenu sectionId={id} layout="cards" />
|
|
76
|
+
{/if}
|
|
77
|
+
{@render children?.()}
|
|
78
|
+
{:else}
|
|
79
|
+
<h1>{t(M['ui.cookbook.section_not_found'])}</h1>
|
|
80
|
+
<p>{t(M['ui.cookbook.section_not_found_hint'])}</p>
|
|
81
|
+
{/if}
|
|
82
|
+
</main>
|
|
83
|
+
|
|
84
|
+
<style>
|
|
85
|
+
.cookbook-section {
|
|
86
|
+
display: grid;
|
|
87
|
+
gap: var(--smrt-spacing-6);
|
|
88
|
+
width: min(100%, 64rem);
|
|
89
|
+
margin-inline: auto;
|
|
90
|
+
padding: var(--smrt-spacing-6);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
header {
|
|
94
|
+
display: flex;
|
|
95
|
+
align-items: center;
|
|
96
|
+
gap: var(--smrt-spacing-4);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.mark {
|
|
100
|
+
display: inline-grid;
|
|
101
|
+
place-items: center;
|
|
102
|
+
flex: 0 0 auto;
|
|
103
|
+
inline-size: 3rem;
|
|
104
|
+
block-size: 3rem;
|
|
105
|
+
border-radius: var(--smrt-radius-large, 0.75rem);
|
|
106
|
+
background: var(--smrt-color-primary-container);
|
|
107
|
+
color: var(--smrt-color-on-primary-container);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.heading {
|
|
111
|
+
flex: 1 1 auto;
|
|
112
|
+
min-inline-size: 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
h1 {
|
|
116
|
+
margin: 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.heading p {
|
|
120
|
+
margin: var(--smrt-spacing-1) 0 0;
|
|
121
|
+
color: var(--smrt-color-on-surface-variant);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.entries {
|
|
125
|
+
display: grid;
|
|
126
|
+
gap: var(--smrt-spacing-3);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.entries h2 {
|
|
130
|
+
margin: 0;
|
|
131
|
+
font-size: var(--smrt-typography-title-medium-size, 1rem);
|
|
132
|
+
}
|
|
133
|
+
</style>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { CookbookSectionPageProps } from './types.js';
|
|
2
|
+
declare const CookbookSectionPage: import("svelte").Component<CookbookSectionPageProps, {}, "">;
|
|
3
|
+
type CookbookSectionPage = ReturnType<typeof CookbookSectionPage>;
|
|
4
|
+
export default CookbookSectionPage;
|
|
5
|
+
//# sourceMappingURL=CookbookSectionPage.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"CookbookSectionPage.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/cookbook/CookbookSectionPage.svelte.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAC;AAyE3D,QAAA,MAAM,mBAAmB,8DAAwC,CAAC;AAClE,KAAK,mBAAmB,GAAG,UAAU,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAClE,eAAe,mBAAmB,CAAC"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type ThemePreset } from '@happyvertical/smrt-ui/themes';
|
|
2
|
+
import type { ResolvedCookbookTheme } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* The preset id `ThemeProvider` should use for a resolved cookbook theme.
|
|
5
|
+
* A brand colour is registered through `createThemeFromColor` first; a
|
|
6
|
+
* registered id is never reused for another colour because `ThemeProvider`
|
|
7
|
+
* looks a theme up once per preset id (the id encodes colour and font).
|
|
8
|
+
* An unknown preset name falls back to the default rather than breaking.
|
|
9
|
+
*/
|
|
10
|
+
export declare function applyCookbookTheme(theme: ResolvedCookbookTheme): ThemePreset;
|
|
11
|
+
//# sourceMappingURL=brand-theme.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"brand-theme.d.ts","sourceRoot":"","sources":["../../../src/components/cookbook/brand-theme.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,WAAW,EACjB,MAAM,+BAA+B,CAAC;AAEvC,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAIxD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,qBAAqB,GAAG,WAAW,CAiB5E"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createThemeFromColor, isValidPreset, registerTheme, } from '@happyvertical/smrt-ui/themes';
|
|
2
|
+
import { DEFAULT_THEME_PRESET, THEME_FONTS } from './theme.js';
|
|
3
|
+
const registered = new Set();
|
|
4
|
+
/**
|
|
5
|
+
* The preset id `ThemeProvider` should use for a resolved cookbook theme.
|
|
6
|
+
* A brand colour is registered through `createThemeFromColor` first; a
|
|
7
|
+
* registered id is never reused for another colour because `ThemeProvider`
|
|
8
|
+
* looks a theme up once per preset id (the id encodes colour and font).
|
|
9
|
+
* An unknown preset name falls back to the default rather than breaking.
|
|
10
|
+
*/
|
|
11
|
+
export function applyCookbookTheme(theme) {
|
|
12
|
+
const { brand } = theme;
|
|
13
|
+
if (brand) {
|
|
14
|
+
if (!registered.has(theme.preset)) {
|
|
15
|
+
const font = brand.fontFamily ? THEME_FONTS[brand.fontFamily] : undefined;
|
|
16
|
+
registerTheme(createThemeFromColor(brand.primary, theme.preset, 'Brand colour', {
|
|
17
|
+
...(font ? { fontFamily: font } : {}),
|
|
18
|
+
}));
|
|
19
|
+
registered.add(theme.preset);
|
|
20
|
+
}
|
|
21
|
+
return theme.preset;
|
|
22
|
+
}
|
|
23
|
+
return (isValidPreset(theme.preset) ? theme.preset : DEFAULT_THEME_PRESET);
|
|
24
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { BuildCookbookShellOptions, CookbookShell } from './types.js';
|
|
2
|
+
/** `PurchaseOrder` -> `Purchase Order`. */
|
|
3
|
+
export declare function humanize(name: string): string;
|
|
4
|
+
/** `Customer` -> `Customers`, `Category` -> `Categories`. */
|
|
5
|
+
export declare function pluralize(noun: string): string;
|
|
6
|
+
/** Derive the shell configuration from a cookbook (see {@link CookbookShell}). */
|
|
7
|
+
export declare function buildCookbookShell(options: BuildCookbookShellOptions): CookbookShell;
|
|
8
|
+
//# sourceMappingURL=build.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../../../src/components/cookbook/build.ts"],"names":[],"mappings":"AAqCA,OAAO,KAAK,EACV,yBAAyB,EAQzB,aAAa,EAEd,MAAM,YAAY,CAAC;AAEpB,2CAA2C;AAC3C,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7C;AAED,6DAA6D;AAC7D,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAI9C;AA0ED,kFAAkF;AAClF,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,yBAAyB,GACjC,aAAa,CAyOf"}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { defineOverview, isEmptyOverride, parseOverviewOverride, } from '../overview/model.js';
|
|
2
|
+
import { OVERVIEW_PAGE_ID_PATTERN, } from '../overview/types.js';
|
|
3
|
+
import { buildNavSections, DEFAULT_ENTRY_ICON, defaultEntryPath, defaultSectionPath, entryDescription, FEATURE_SECTION, navItemId, sectionLayoutId, } from './sections.js';
|
|
4
|
+
import { resolveCookbookTheme } from './theme.js';
|
|
5
|
+
/** `PurchaseOrder` -> `Purchase Order`. */
|
|
6
|
+
export function humanize(name) {
|
|
7
|
+
return name.replace(/([a-z0-9])([A-Z])/g, '$1 $2');
|
|
8
|
+
}
|
|
9
|
+
/** `Customer` -> `Customers`, `Category` -> `Categories`. */
|
|
10
|
+
export function pluralize(noun) {
|
|
11
|
+
if (/[^aeiou]y$/i.test(noun))
|
|
12
|
+
return `${noun.slice(0, -1)}ies`;
|
|
13
|
+
if (/(s|x|z|ch|sh)$/i.test(noun))
|
|
14
|
+
return `${noun}es`;
|
|
15
|
+
return `${noun}s`;
|
|
16
|
+
}
|
|
17
|
+
/** The recipes the cookbook selects, in catalog order; unknown ids are reported. */
|
|
18
|
+
function selectRecipes(options, issues) {
|
|
19
|
+
const { cookbook, catalog, recipeOrder } = options;
|
|
20
|
+
const byId = new Map(catalog.recipes.map((r) => [r.id, r]));
|
|
21
|
+
const wanted = new Set(cookbook.recipes);
|
|
22
|
+
const order = [...(recipeOrder ?? []), ...catalog.recipes.map((r) => r.id)];
|
|
23
|
+
const picked = [];
|
|
24
|
+
const done = new Set();
|
|
25
|
+
for (const id of order) {
|
|
26
|
+
const recipe = byId.get(id);
|
|
27
|
+
if (!recipe || !wanted.has(id) || done.has(id))
|
|
28
|
+
continue;
|
|
29
|
+
done.add(id);
|
|
30
|
+
picked.push(recipe);
|
|
31
|
+
}
|
|
32
|
+
for (const id of cookbook.recipes) {
|
|
33
|
+
if (!done.has(id))
|
|
34
|
+
issues.push(`Unknown recipe "${id}".`);
|
|
35
|
+
}
|
|
36
|
+
return picked;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The section's lead model: the first nav entry of the first recipe that
|
|
40
|
+
* suggests the section. Its count and records go on the section page.
|
|
41
|
+
*/
|
|
42
|
+
function leadModel(recipes, models) {
|
|
43
|
+
for (const recipe of recipes) {
|
|
44
|
+
for (const entry of recipe.nav) {
|
|
45
|
+
const model = models.get(entry.model);
|
|
46
|
+
if (model)
|
|
47
|
+
return model;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
function overviewDefaults(lead, dataWidgets) {
|
|
53
|
+
const widgets = [
|
|
54
|
+
{ id: 'shortcuts', type: 'shortcuts', span: 4, options: {} },
|
|
55
|
+
];
|
|
56
|
+
if (dataWidgets && lead) {
|
|
57
|
+
const name = humanize(lead.name);
|
|
58
|
+
widgets.push({
|
|
59
|
+
id: 'count',
|
|
60
|
+
type: 'metric',
|
|
61
|
+
span: 1,
|
|
62
|
+
options: { title: pluralize(name), model: lead.id, measure: 'count' },
|
|
63
|
+
}, {
|
|
64
|
+
id: 'latest',
|
|
65
|
+
type: 'records',
|
|
66
|
+
span: 3,
|
|
67
|
+
options: { title: `${name} records`, model: lead.id, limit: 5 },
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return widgets;
|
|
71
|
+
}
|
|
72
|
+
/** The cookbook layout as the shell takes it: `null` when it has none. */
|
|
73
|
+
function layoutOf(cookbook) {
|
|
74
|
+
return cookbook.layout ? cookbook.layout : null;
|
|
75
|
+
}
|
|
76
|
+
/** Derive the shell configuration from a cookbook (see {@link CookbookShell}). */
|
|
77
|
+
export function buildCookbookShell(options) {
|
|
78
|
+
const { cookbook, catalog } = options;
|
|
79
|
+
const issues = [];
|
|
80
|
+
const href = options.href ?? ((path) => path);
|
|
81
|
+
const entryPath = options.entryPath ?? defaultEntryPath;
|
|
82
|
+
const sectionPath = options.sectionPath ?? defaultSectionPath;
|
|
83
|
+
const models = new Map(catalog.models.map((m) => [m.id, m]));
|
|
84
|
+
const recipes = selectRecipes(options, issues);
|
|
85
|
+
const entries = new Map();
|
|
86
|
+
const sections = [];
|
|
87
|
+
const navGroups = [];
|
|
88
|
+
const addEntry = (recipe, spec) => {
|
|
89
|
+
const id = navItemId(spec.model.packageId, spec.model.name, spec.key);
|
|
90
|
+
if (entries.has(id))
|
|
91
|
+
return undefined;
|
|
92
|
+
const path = entryPath({
|
|
93
|
+
packageId: spec.model.packageId,
|
|
94
|
+
modelName: spec.model.name,
|
|
95
|
+
...(spec.key ? { key: spec.key } : {}),
|
|
96
|
+
});
|
|
97
|
+
const entry = {
|
|
98
|
+
id,
|
|
99
|
+
label: spec.label,
|
|
100
|
+
icon: spec.icon ?? DEFAULT_ENTRY_ICON,
|
|
101
|
+
description: entryDescription(spec.description, spec.model),
|
|
102
|
+
model: spec.model,
|
|
103
|
+
path,
|
|
104
|
+
href: href(path),
|
|
105
|
+
recipeId: recipe?.id ?? null,
|
|
106
|
+
...(spec.noun ? { noun: spec.noun } : {}),
|
|
107
|
+
...(spec.filter ? { filter: spec.filter } : {}),
|
|
108
|
+
};
|
|
109
|
+
entries.set(id, entry);
|
|
110
|
+
return entry;
|
|
111
|
+
};
|
|
112
|
+
const toItem = (entry) => ({
|
|
113
|
+
id: entry.id,
|
|
114
|
+
href: entry.href,
|
|
115
|
+
label: entry.label,
|
|
116
|
+
icon: entry.icon,
|
|
117
|
+
description: entry.description,
|
|
118
|
+
});
|
|
119
|
+
const pushSection = (id, label, icon, description, recipeIds, items) => {
|
|
120
|
+
const layoutId = sectionLayoutId(id);
|
|
121
|
+
const path = sectionPath(layoutId);
|
|
122
|
+
sections.push({
|
|
123
|
+
id: layoutId,
|
|
124
|
+
label,
|
|
125
|
+
...(icon ? { icon } : {}),
|
|
126
|
+
...(description ? { description } : {}),
|
|
127
|
+
entryIds: items.map((e) => e.id),
|
|
128
|
+
recipeIds,
|
|
129
|
+
path,
|
|
130
|
+
});
|
|
131
|
+
navGroups.push({
|
|
132
|
+
id: layoutId,
|
|
133
|
+
heading: label,
|
|
134
|
+
...(icon ? { icon } : {}),
|
|
135
|
+
href: href(path),
|
|
136
|
+
items: items.map(toItem),
|
|
137
|
+
});
|
|
138
|
+
};
|
|
139
|
+
// Recipe sections: a section none of whose recipes has a menu entry (an
|
|
140
|
+
// assistant, a settings panel) is left out rather than shown empty.
|
|
141
|
+
const leads = new Map();
|
|
142
|
+
for (const section of buildNavSections(recipes)) {
|
|
143
|
+
const items = [];
|
|
144
|
+
for (const recipe of section.recipes) {
|
|
145
|
+
for (const nav of recipe.nav) {
|
|
146
|
+
const model = models.get(nav.model);
|
|
147
|
+
if (!model) {
|
|
148
|
+
issues.push(`Recipe ${recipe.id} lists unknown model ${nav.model}.`);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const entry = addEntry(recipe, {
|
|
152
|
+
model,
|
|
153
|
+
label: nav.label,
|
|
154
|
+
icon: nav.icon,
|
|
155
|
+
description: nav.description,
|
|
156
|
+
key: nav.key,
|
|
157
|
+
noun: nav.noun,
|
|
158
|
+
filter: nav.filter,
|
|
159
|
+
});
|
|
160
|
+
if (entry)
|
|
161
|
+
items.push(entry);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (items.length === 0)
|
|
165
|
+
continue;
|
|
166
|
+
leads.set(sectionLayoutId(section.id), leadModel(section.recipes, models));
|
|
167
|
+
pushSection(section.id, section.label, section.icon, section.description ??
|
|
168
|
+
section.recipes.find((r) => r.group?.summary)?.group?.summary, section.recipes.map((r) => r.id), items);
|
|
169
|
+
}
|
|
170
|
+
// Features: exposed models no recipe covers, in one section after the recipes'.
|
|
171
|
+
const featureItems = [];
|
|
172
|
+
for (const id of cookbook.features ?? []) {
|
|
173
|
+
const model = models.get(id);
|
|
174
|
+
if (!model?.exposed) {
|
|
175
|
+
issues.push(`Feature ${id} is not an exposed model.`);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const entry = addEntry(null, { model, label: humanize(model.name) });
|
|
179
|
+
if (entry)
|
|
180
|
+
featureItems.push(entry);
|
|
181
|
+
}
|
|
182
|
+
if (featureItems.length > 0) {
|
|
183
|
+
leads.set(sectionLayoutId(FEATURE_SECTION.id), featureItems[0]?.model);
|
|
184
|
+
pushSection(FEATURE_SECTION.id, FEATURE_SECTION.label, FEATURE_SECTION.icon, FEATURE_SECTION.description, [], featureItems);
|
|
185
|
+
}
|
|
186
|
+
// Section page overviews: one per suggested section and per custom section
|
|
187
|
+
// of the layout, so a cookbook's `overviews` key names the page it
|
|
188
|
+
// customises and stays valid when the section is renamed.
|
|
189
|
+
const layout = layoutOf(cookbook);
|
|
190
|
+
const modelIds = catalog.models.map((m) => m.id);
|
|
191
|
+
const overviews = {};
|
|
192
|
+
const overviewIds = [
|
|
193
|
+
...sections.map((s) => s.id),
|
|
194
|
+
...(layout?.customSections ?? []).map((s) => s.id),
|
|
195
|
+
];
|
|
196
|
+
for (const id of new Set(overviewIds)) {
|
|
197
|
+
// A hand-edited custom id outside the pattern has no overview: its page
|
|
198
|
+
// shows the menu.
|
|
199
|
+
if (!OVERVIEW_PAGE_ID_PATTERN.test(id))
|
|
200
|
+
continue;
|
|
201
|
+
const definition = defineOverview({
|
|
202
|
+
id,
|
|
203
|
+
...(modelIds.length > 0 ? { models: modelIds } : {}),
|
|
204
|
+
defaults: overviewDefaults(leads.get(id), options.dataWidgets === true),
|
|
205
|
+
});
|
|
206
|
+
const stored = cookbook.overviews?.[id];
|
|
207
|
+
const parsed = parseOverviewOverride(stored);
|
|
208
|
+
for (const issue of parsed.issues) {
|
|
209
|
+
issues.push(`Overview ${id}: ${issue.message}`);
|
|
210
|
+
}
|
|
211
|
+
overviews[id] = {
|
|
212
|
+
definition,
|
|
213
|
+
override: isEmptyOverride(parsed.override) ? null : stored,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
for (const id of Object.keys(cookbook.overviews ?? {})) {
|
|
217
|
+
if (!overviews[id]) {
|
|
218
|
+
issues.push(`Overview "${id}" names no section; its override is ignored.`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// Recipe surfaces placed into shell slots and routes.
|
|
222
|
+
const shellWidgets = [];
|
|
223
|
+
const routes = [];
|
|
224
|
+
const settingsPanels = [];
|
|
225
|
+
const widgets = [];
|
|
226
|
+
for (const recipe of recipes) {
|
|
227
|
+
const found = [];
|
|
228
|
+
for (const surface of recipe.surfaces ?? []) {
|
|
229
|
+
if (surface.kind === 'shell-widget') {
|
|
230
|
+
shellWidgets.push({
|
|
231
|
+
recipeId: recipe.id,
|
|
232
|
+
slot: surface.slot,
|
|
233
|
+
export: surface.export,
|
|
234
|
+
label: surface.label,
|
|
235
|
+
...(surface.icon ? { icon: surface.icon } : {}),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
else if (surface.kind === 'route') {
|
|
239
|
+
routes.push({
|
|
240
|
+
recipeId: recipe.id,
|
|
241
|
+
path: surface.path,
|
|
242
|
+
export: surface.export,
|
|
243
|
+
label: surface.label,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
else if (surface.kind === 'settings-panel') {
|
|
247
|
+
settingsPanels.push({
|
|
248
|
+
recipeId: recipe.id,
|
|
249
|
+
export: surface.export,
|
|
250
|
+
label: surface.label,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
else if (surface.kind === 'widget')
|
|
254
|
+
found.push(surface);
|
|
255
|
+
}
|
|
256
|
+
if (found.length > 0)
|
|
257
|
+
widgets.push({ id: recipe.id, surfaces: found });
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
title: options.title ?? cookbook.name ?? 'SMRT',
|
|
261
|
+
navMode: 'sections',
|
|
262
|
+
navGroups,
|
|
263
|
+
layout,
|
|
264
|
+
sections,
|
|
265
|
+
entries,
|
|
266
|
+
theme: resolveCookbookTheme(cookbook.theme),
|
|
267
|
+
overviews,
|
|
268
|
+
shellWidgets,
|
|
269
|
+
routes,
|
|
270
|
+
settingsPanels,
|
|
271
|
+
widgets,
|
|
272
|
+
policies: cookbook.policies ?? [],
|
|
273
|
+
...(options.settingsHref ? { settingsHref: options.settingsHref } : {}),
|
|
274
|
+
sectionPath: (id) => href(sectionPath(id)),
|
|
275
|
+
issues,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { CookbookCatalog } from './types.js';
|
|
2
|
+
/** The subset of a package `manifest.json` the shell reads. */
|
|
3
|
+
export interface CookbookManifest {
|
|
4
|
+
packageName: string;
|
|
5
|
+
objects?: Record<string, RawManifestObject>;
|
|
6
|
+
recipes?: unknown;
|
|
7
|
+
}
|
|
8
|
+
interface RawManifestObject {
|
|
9
|
+
className?: string;
|
|
10
|
+
qualifiedName?: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
fields?: Record<string, {
|
|
13
|
+
type?: string;
|
|
14
|
+
}>;
|
|
15
|
+
decoratorConfig?: Record<string, unknown>;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* `@happyvertical/smrt-commerce` -> `commerce`. Other scopes keep their
|
|
19
|
+
* unscoped name with a leading `smrt-` dropped, so ids stay short and stable.
|
|
20
|
+
*/
|
|
21
|
+
export declare function packageIdOf(packageName: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* The catalog of the given manifests. Recipes keep their declaration order
|
|
24
|
+
* across manifests (the order given); a repeated recipe id keeps the first.
|
|
25
|
+
*/
|
|
26
|
+
export declare function catalogFromManifests(manifests: readonly CookbookManifest[]): CookbookCatalog;
|
|
27
|
+
/**
|
|
28
|
+
* Narrow a catalog to what a cookbook uses, so a server load serializes only
|
|
29
|
+
* that: the selected recipes, and the models they list or the cookbook names
|
|
30
|
+
* as features.
|
|
31
|
+
*/
|
|
32
|
+
export declare function narrowCatalog(catalog: CookbookCatalog, selection: {
|
|
33
|
+
recipes: readonly string[];
|
|
34
|
+
features: readonly string[];
|
|
35
|
+
}): CookbookCatalog;
|
|
36
|
+
export {};
|
|
37
|
+
//# sourceMappingURL=catalog.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"catalog.d.ts","sourceRoot":"","sources":["../../../src/components/cookbook/catalog.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EACV,eAAe,EAGhB,MAAM,YAAY,CAAC;AAEpB,+DAA+D;AAC/D,MAAM,WAAW,gBAAgB;IAC/B,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC;IAC5C,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,UAAU,iBAAiB;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC3C,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC3C;AAKD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAQvD;AAoDD;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,SAAS,EAAE,SAAS,gBAAgB,EAAE,GACrC,eAAe,CA0BjB;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,eAAe,EACxB,SAAS,EAAE;IAAE,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAA;CAAE,GACrE,eAAe,CASjB"}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
const PACKAGE_PREFIXES = ['@happyvertical/smrt-'];
|
|
2
|
+
const NON_VALUE_TYPES = new Set(['oneToMany', 'meta']);
|
|
3
|
+
/**
|
|
4
|
+
* `@happyvertical/smrt-commerce` -> `commerce`. Other scopes keep their
|
|
5
|
+
* unscoped name with a leading `smrt-` dropped, so ids stay short and stable.
|
|
6
|
+
*/
|
|
7
|
+
export function packageIdOf(packageName) {
|
|
8
|
+
for (const prefix of PACKAGE_PREFIXES) {
|
|
9
|
+
if (packageName.startsWith(prefix))
|
|
10
|
+
return packageName.slice(prefix.length);
|
|
11
|
+
}
|
|
12
|
+
const bare = packageName.includes('/')
|
|
13
|
+
? packageName.slice(packageName.indexOf('/') + 1)
|
|
14
|
+
: packageName;
|
|
15
|
+
return bare.replace(/^smrt-/, '');
|
|
16
|
+
}
|
|
17
|
+
/** A model, not a collection class: it holds at least one value field. */
|
|
18
|
+
function isModel(raw) {
|
|
19
|
+
return Object.values(raw.fields ?? {}).some((f) => !NON_VALUE_TYPES.has(f.type ?? ''));
|
|
20
|
+
}
|
|
21
|
+
/** An omitted `api`/`mcp`/`cli` key means full CRUD; all three `false` means none. */
|
|
22
|
+
function isExposed(config) {
|
|
23
|
+
const c = config ?? {};
|
|
24
|
+
return !(c.api === false && c.mcp === false && c.cli === false);
|
|
25
|
+
}
|
|
26
|
+
function isRecipe(value) {
|
|
27
|
+
if (!value || typeof value !== 'object')
|
|
28
|
+
return false;
|
|
29
|
+
const r = value;
|
|
30
|
+
return (typeof r.id === 'string' &&
|
|
31
|
+
typeof r.label === 'string' &&
|
|
32
|
+
Array.isArray(r.nav) &&
|
|
33
|
+
Array.isArray(r.models));
|
|
34
|
+
}
|
|
35
|
+
/** Drop what the shell never reads (help markdown, synonyms, providers, ...). */
|
|
36
|
+
function trimRecipe(recipe) {
|
|
37
|
+
const { id, label, nav, models, group, section, surfaces, requires, requiresAny, } = recipe;
|
|
38
|
+
return {
|
|
39
|
+
id,
|
|
40
|
+
label,
|
|
41
|
+
nav,
|
|
42
|
+
models,
|
|
43
|
+
...(group ? { group } : {}),
|
|
44
|
+
...(section ? { section } : {}),
|
|
45
|
+
...(surfaces && surfaces.length > 0 ? { surfaces } : {}),
|
|
46
|
+
...(requires ? { requires } : {}),
|
|
47
|
+
...(requiresAny ? { requiresAny } : {}),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The catalog of the given manifests. Recipes keep their declaration order
|
|
52
|
+
* across manifests (the order given); a repeated recipe id keeps the first.
|
|
53
|
+
*/
|
|
54
|
+
export function catalogFromManifests(manifests) {
|
|
55
|
+
const recipes = [];
|
|
56
|
+
const seen = new Set();
|
|
57
|
+
const models = [];
|
|
58
|
+
for (const manifest of manifests) {
|
|
59
|
+
const packageId = packageIdOf(manifest.packageName);
|
|
60
|
+
for (const raw of Object.values(manifest.objects ?? {})) {
|
|
61
|
+
if (!raw.qualifiedName || !raw.className || !isModel(raw))
|
|
62
|
+
continue;
|
|
63
|
+
const description = raw.description?.trim();
|
|
64
|
+
models.push({
|
|
65
|
+
id: raw.qualifiedName,
|
|
66
|
+
name: raw.className,
|
|
67
|
+
packageId,
|
|
68
|
+
exposed: isExposed(raw.decoratorConfig),
|
|
69
|
+
...(description ? { description } : {}),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
if (Array.isArray(manifest.recipes)) {
|
|
73
|
+
for (const recipe of manifest.recipes) {
|
|
74
|
+
if (!isRecipe(recipe) || seen.has(recipe.id))
|
|
75
|
+
continue;
|
|
76
|
+
seen.add(recipe.id);
|
|
77
|
+
recipes.push(trimRecipe(recipe));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { recipes, models };
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Narrow a catalog to what a cookbook uses, so a server load serializes only
|
|
85
|
+
* that: the selected recipes, and the models they list or the cookbook names
|
|
86
|
+
* as features.
|
|
87
|
+
*/
|
|
88
|
+
export function narrowCatalog(catalog, selection) {
|
|
89
|
+
const wanted = new Set(selection.recipes);
|
|
90
|
+
const recipes = catalog.recipes.filter((r) => wanted.has(r.id));
|
|
91
|
+
const modelIds = new Set(selection.features);
|
|
92
|
+
for (const recipe of recipes) {
|
|
93
|
+
for (const id of recipe.models)
|
|
94
|
+
modelIds.add(id);
|
|
95
|
+
for (const entry of recipe.nav)
|
|
96
|
+
modelIds.add(entry.model);
|
|
97
|
+
}
|
|
98
|
+
return { recipes, models: catalog.models.filter((m) => modelIds.has(m.id)) };
|
|
99
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { OverviewModelChoice } from '../overview/grid-types.js';
|
|
2
|
+
import type { WidgetComponents, WidgetRegistry } from '../overview/registry.js';
|
|
3
|
+
import type { OverviewOverride } from '../overview/types.js';
|
|
4
|
+
import type { CookbookShell } from './types.js';
|
|
5
|
+
/** What `CookbookApp` shares with `CookbookSectionPage` below it. */
|
|
6
|
+
export interface CookbookContext {
|
|
7
|
+
readonly shell: CookbookShell;
|
|
8
|
+
/** `null` until the recipe widgets are registered. */
|
|
9
|
+
readonly registry: WidgetRegistry | null;
|
|
10
|
+
readonly components: WidgetComponents;
|
|
11
|
+
/** Models the overview option editor offers. */
|
|
12
|
+
readonly models: readonly OverviewModelChoice[];
|
|
13
|
+
/** The page's stored override (untrusted JSON), reactive. */
|
|
14
|
+
overrideFor(sectionId: string): unknown;
|
|
15
|
+
/** Store an edit; `null` resets the page to its defaults. */
|
|
16
|
+
setOverride(sectionId: string, override: OverviewOverride | null): void;
|
|
17
|
+
/** Host capabilities passed to widget loaders (a data source, ...). */
|
|
18
|
+
widgetContext(): Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
export declare function setCookbookContext(context: CookbookContext): void;
|
|
21
|
+
export declare function tryGetCookbookContext(): CookbookContext | undefined;
|
|
22
|
+
export declare function getCookbookContext(): CookbookContext;
|
|
23
|
+
//# sourceMappingURL=context.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../../../src/components/cookbook/context.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AACrE,OAAO,KAAK,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAChF,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAC7D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD,qEAAqE;AACrE,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAC;IAC9B,sDAAsD;IACtD,QAAQ,CAAC,QAAQ,EAAE,cAAc,GAAG,IAAI,CAAC;IACzC,QAAQ,CAAC,UAAU,EAAE,gBAAgB,CAAC;IACtC,gDAAgD;IAChD,QAAQ,CAAC,MAAM,EAAE,SAAS,mBAAmB,EAAE,CAAC;IAChD,6DAA6D;IAC7D,WAAW,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC;IACxC,6DAA6D;IAC7D,WAAW,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,gBAAgB,GAAG,IAAI,GAAG,IAAI,CAAC;IACxE,uEAAuE;IACvE,aAAa,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC1C;AAID,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,eAAe,GAAG,IAAI,CAEjE;AAED,wBAAgB,qBAAqB,IAAI,eAAe,GAAG,SAAS,CAEnE;AAED,wBAAgB,kBAAkB,IAAI,eAAe,CAQpD"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { getContext, setContext } from 'svelte';
|
|
2
|
+
const KEY = Symbol('smrt-cookbook');
|
|
3
|
+
export function setCookbookContext(context) {
|
|
4
|
+
setContext(KEY, context);
|
|
5
|
+
}
|
|
6
|
+
export function tryGetCookbookContext() {
|
|
7
|
+
return getContext(KEY);
|
|
8
|
+
}
|
|
9
|
+
export function getCookbookContext() {
|
|
10
|
+
const context = tryGetCookbookContext();
|
|
11
|
+
if (!context) {
|
|
12
|
+
throw new Error('CookbookSectionPage must be rendered inside <CookbookApp>.');
|
|
13
|
+
}
|
|
14
|
+
return context;
|
|
15
|
+
}
|