@getvitops/emdash 1.0.0-alpha.0 → 2.0.0-alpha.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +279 -3
  2. package/README.md +66 -21
  3. package/dist/index.d.mts +82 -18
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +1626 -152
  6. package/dist/index.mjs.map +1 -1
  7. package/package.json +16 -13
  8. package/src/admin/DesignSystemEditorPage.tsx +15 -42
  9. package/src/astro/blocks/Accordion.astro +39 -0
  10. package/src/astro/blocks/ActionLink.astro +4 -2
  11. package/src/astro/blocks/Callout.astro +73 -0
  12. package/src/astro/blocks/Cards.astro +96 -0
  13. package/src/astro/blocks/Carousel.astro +25 -8
  14. package/src/astro/blocks/Embed.astro +67 -0
  15. package/src/astro/blocks/Entries.astro +30 -0
  16. package/src/astro/blocks/Form.astro +194 -0
  17. package/src/astro/blocks/Gallery.astro +40 -0
  18. package/src/astro/blocks/Hero.astro +115 -0
  19. package/src/astro/blocks/ImageCompare.astro +5 -2
  20. package/src/astro/blocks/Logos.astro +48 -0
  21. package/src/astro/blocks/Marquee.astro +38 -0
  22. package/src/astro/blocks/Nav.astro +85 -0
  23. package/src/astro/blocks/NavTree.astro +48 -0
  24. package/src/astro/blocks/Pricing.astro +66 -0
  25. package/src/astro/blocks/Reviews.astro +122 -0
  26. package/src/astro/blocks/RichText.astro +14 -0
  27. package/src/astro/blocks/Section.astro +84 -0
  28. package/src/astro/blocks/Stats.astro +59 -0
  29. package/src/astro/blocks/Steps.astro +48 -0
  30. package/src/astro/blocks/embed.test.ts +43 -0
  31. package/src/astro/blocks/embed.ts +84 -0
  32. package/src/astro/blocks/form.sample.ts +33 -0
  33. package/src/astro/blocks/form.test.ts +55 -0
  34. package/src/astro/blocks/form.ts +158 -0
  35. package/src/astro/blocks/head.stub.ts +2 -0
  36. package/src/astro/blocks/nav.test.ts +51 -0
  37. package/src/astro/blocks/nav.ts +65 -0
  38. package/src/astro/blocks/render.container.test.ts +570 -0
  39. package/src/astro/blocks/tone.test.ts +53 -0
  40. package/src/astro/blocks/tone.ts +122 -0
  41. package/src/astro/blocks/value.test.ts +56 -0
  42. package/src/astro/blocks/value.ts +103 -1
  43. package/src/astro/index.ts +40 -0
  44. package/src/admin/core-editor.d.ts +0 -7
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The shared `tone` convention (UPSTREAM finding 21): every block that colours anything
3
+ * takes one `tone` and, optionally, one `color`.
4
+ *
5
+ * - **`tone`** — a role name from the site's `colors.roles` (`brand-primary`, `danger`, a
6
+ * consumer-named surface role…), or a semantic name. Role first, then its semantic
7
+ * fallbacks, then `neutral`.
8
+ * - **`color`** — the escape hatch: a palette hue (`amber`) or hue and step (`amber-300`).
9
+ * Wins over `tone` when set. Contrast is NOT checked for it — the generator's contrast
10
+ * contract covers roles, not arbitrary hue/step pairs — so it is for decoration.
11
+ *
12
+ * **Why CSS custom properties, not classes.** A class name built from a field value
13
+ * (`bg-${tone}`) is invisible to Tailwind's scanner, so it generates nothing in that
14
+ * format. The resolver instead returns an inline `style` of `--tone-*` properties that
15
+ * the framework's `.tone` pattern (patterns/tone.css) reads. Each property is a `var()`
16
+ * chain, so the fallback happens in CSS at render time and this module needs NO copy of
17
+ * the site's config — a tone the site never defined degrades to `neutral`, not to
18
+ * nothing.
19
+ *
20
+ * The inputs are editor-supplied strings that land inside a `style` attribute, so both are
21
+ * validated against a closed identifier grammar before use; anything else is dropped.
22
+ */
23
+
24
+ /** Semantic names every site is expected to define (`REQUIRED_ROLES`), plus common aliases. */
25
+ export const SEMANTIC_TONES = ['neutral', 'info', 'success', 'warning', 'danger'] as const;
26
+
27
+ /**
28
+ * When a role is absent, which others to try before `neutral`. Covers the framework's own
29
+ * vocabulary (`ui-*`, `brand-*`) so a site that names only some of them still gets the
30
+ * nearest relative rather than a grey block.
31
+ */
32
+ const FALLBACKS: Record<string, string[]> = {
33
+ 'brand-primary': ['ui-primary', 'info'],
34
+ 'brand-secondary': ['ui-secondary', 'info'],
35
+ 'ui-primary': ['info'],
36
+ 'ui-secondary': ['info'],
37
+ 'ui-accent': ['warning'],
38
+ surface: ['neutral'],
39
+ error: ['danger'],
40
+ notice: ['warning'],
41
+ note: ['info'],
42
+ // Retired vitops.ca vocabulary some stored content still carries.
43
+ it: ['ui-primary', 'info'],
44
+ mk: ['ui-accent', 'warning'],
45
+ };
46
+
47
+ const ROLE = /^[a-z][a-z0-9-]{0,40}$/;
48
+ const HUE = /^([a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)*?)(?:-(\d{2,3}))?$/;
49
+
50
+ /** The role chain for a tone: itself, its relatives, then `neutral`. Pure and exported for tests. */
51
+ export function toneChain(tone: unknown): string[] {
52
+ const name = typeof tone === 'string' ? tone.trim().toLowerCase() : '';
53
+ if (!ROLE.test(name)) return ['neutral'];
54
+ return [...new Set([name, ...(FALLBACKS[name] ?? []), 'neutral'])];
55
+ }
56
+
57
+ /** `var(--a, var(--b, …))` over a chain of token names; the last link has no fallback. */
58
+ function chain(tokens: string[]): string {
59
+ return tokens.reduceRight<string>(
60
+ (inner, token) => (inner ? `var(${token}, ${inner})` : `var(${token})`),
61
+ '',
62
+ );
63
+ }
64
+
65
+ export interface ResolvedTone {
66
+ /** Always `tone`; a block adds `tone--solid` itself when it fills rather than tints. */
67
+ className: string;
68
+ /** Inline `style` value of `--tone-*` properties (no trailing semicolon). Empty when unset. */
69
+ style: string;
70
+ }
71
+
72
+ /**
73
+ * Resolve a block's `tone` / `color` fields.
74
+ *
75
+ * Returns an empty style when neither is set, so an unconfigured block carries no tone at
76
+ * all (rather than a neutral one) and stays transparent.
77
+ */
78
+ export function resolveTone(tone?: unknown, color?: unknown): ResolvedTone {
79
+ const hasTone = typeof tone === 'string' && tone.trim() !== '';
80
+ const hue = typeof color === 'string' ? HUE.exec(color.trim().toLowerCase()) : null;
81
+
82
+ if (hue) {
83
+ const [, name, step] = hue;
84
+ const at = (s: string) => `var(--color-${name}-${s})`;
85
+ return {
86
+ className: 'tone',
87
+ style: [
88
+ `--tone-bg: ${at('100')}`,
89
+ `--tone-fg: ${at('900')}`,
90
+ `--tone-border: ${at('300')}`,
91
+ `--tone-solid: ${at(step ?? '600')}`,
92
+ `--tone-on-solid: #fff`,
93
+ ].join('; '),
94
+ };
95
+ }
96
+
97
+ if (!hasTone) return { className: 'tone', style: '' };
98
+
99
+ const roles = toneChain(tone);
100
+ const of = (prefix: string, suffix = '') =>
101
+ chain(roles.map((r) => `--color-${prefix}-${r}${suffix}`));
102
+ return {
103
+ className: 'tone',
104
+ style: [
105
+ `--tone-bg: ${of('bg', '-muted')}`,
106
+ `--tone-fg: ${of('text')}`,
107
+ `--tone-border: ${of('border')}`,
108
+ // A surface role has no `-solid`, only a bare `bg-<role>`; the solid chain ends there.
109
+ `--tone-solid: ${chain([...roles.map((r) => `--color-bg-${r}-solid`), ...roles.map((r) => `--color-bg-${r}`)])}`,
110
+ `--tone-on-solid: ${chain(roles.map((r) => `--color-text-on-${r}`))}`,
111
+ ].join('; '),
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Join a block's own inline style with a resolved tone's, so a renderer can pass one
117
+ * `style` attribute. Empty parts are skipped.
118
+ */
119
+ export function joinStyle(...parts: (string | undefined | false)[]): string | undefined {
120
+ const out = parts.filter(Boolean).join('; ');
121
+ return out || undefined;
122
+ }
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from 'vite-plus/test';
2
+ import { accentRuns, headingTag, lines, linkRuns, pairs, paragraphs, rows, text } from './value.ts';
3
+
4
+ describe('editor-text helpers', () => {
5
+ it('headingTag clamps to h2-h6 and falls back', () => {
6
+ expect(headingTag('3')).toBe('h3');
7
+ expect(headingTag(1)).toBe('h2');
8
+ expect(headingTag(9, 4)).toBe('h4');
9
+ expect(headingTag(undefined)).toBe('h2');
10
+ });
11
+
12
+ it('linkRuns makes links, drops unsafe hrefs and leaves HTML literal', () => {
13
+ expect(linkRuns('pay [less](/a) for <b>x</b>')).toEqual([
14
+ { text: 'pay ' },
15
+ { text: 'less', href: '/a' },
16
+ { text: ' for <b>x</b>' },
17
+ ]);
18
+ expect(linkRuns('[x](javascript:alert)')).toEqual([{ text: 'x' }]);
19
+ });
20
+
21
+ it('accentRuns marks starred runs', () => {
22
+ expect(accentRuns('Two. *One team.*')).toEqual([
23
+ { text: 'Two. ', accent: false },
24
+ { text: 'One team.', accent: true },
25
+ ]);
26
+ expect(accentRuns('lone * star')).toEqual([{ text: 'lone * star', accent: false }]);
27
+ });
28
+
29
+ it('pairs and lines split editor fields', () => {
30
+ expect(pairs('Mon | 9-5\n\nTue|closed\nnote')).toEqual([
31
+ { term: 'Mon', value: '9-5' },
32
+ { term: 'Tue', value: 'closed' },
33
+ { term: 'note', value: '' },
34
+ ]);
35
+ expect(lines(' a \n\nb')).toEqual(['a', 'b']);
36
+ });
37
+ });
38
+
39
+ describe('repeater helpers', () => {
40
+ it('rows keeps object rows and drops everything else', () => {
41
+ expect(rows([{ a: 1 }, null, 'x', [1], { b: 2 }])).toEqual([{ a: 1 }, { b: 2 }]);
42
+ expect(rows(undefined)).toEqual([]);
43
+ expect(rows('not an array')).toEqual([]);
44
+ });
45
+
46
+ it('text trims strings and treats other values as empty', () => {
47
+ expect(text(' hi ')).toBe('hi');
48
+ expect(text(5)).toBe('');
49
+ expect(text(undefined)).toBe('');
50
+ });
51
+
52
+ it('paragraphs splits on blank lines only', () => {
53
+ expect(paragraphs('one\ntwo\n\n three \n\n\n')).toEqual(['one\ntwo', 'three']);
54
+ expect(paragraphs('')).toEqual([]);
55
+ });
56
+ });
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Normalize the props EmDash passes to a Portable Text block component.
3
3
  *
4
- * Verified against emdash 0.31: <PortableText> delegates to astro-portabletext,
4
+ * Verified against emdash 1.4: <PortableText> still delegates to astro-portabletext,
5
5
  * which passes the block object as `props.node` (fields flat on the block next
6
6
  * to _type/_key). The `value` and spread branches are kept as tolerance for the
7
7
  * shapes shown elsewhere in the EmDash docs, so a host-side change is a
@@ -14,6 +14,10 @@ export function resolveBlockValue<T extends Record<string, unknown>>(
14
14
  return candidate as T;
15
15
  }
16
16
 
17
+ // Shared with the Astro components and `nodesToHtml`; re-exported so blocks import one place.
18
+ import { safeHref } from '@getvitops/utils/url';
19
+ export { safeHref };
20
+
17
21
  /** Split a newline/comma-separated editor field into trimmed non-empty items. */
18
22
  export function splitLines(input: unknown): string[] {
19
23
  if (typeof input !== 'string') return [];
@@ -22,3 +26,101 @@ export function splitLines(input: unknown): string[] {
22
26
  .map((s) => s.trim())
23
27
  .filter(Boolean);
24
28
  }
29
+
30
+ /** A repeater field's saved value: an array of row objects. Anything else is no rows. */
31
+ export function rows<T extends Record<string, unknown> = Record<string, unknown>>(
32
+ input: unknown,
33
+ ): T[] {
34
+ return Array.isArray(input)
35
+ ? (input.filter((row) => row && typeof row === 'object' && !Array.isArray(row)) as T[])
36
+ : [];
37
+ }
38
+
39
+ /** A trimmed string field, or `''` when the value is absent or not a string. */
40
+ export function text(input: unknown): string {
41
+ return typeof input === 'string' ? input.trim() : '';
42
+ }
43
+
44
+ /** Split a multiline editor field into paragraphs on blank lines. */
45
+ export function paragraphs(input: unknown): string[] {
46
+ return text(input)
47
+ .split(/\n\s*\n/)
48
+ .map((p) => p.trim())
49
+ .filter(Boolean);
50
+ }
51
+
52
+ /** A heading element chosen by an editor's `level` field, clamped to `h2`–`h6`. `h1` is the page's title. */
53
+ export function headingTag(
54
+ level: unknown,
55
+ fallback: 2 | 3 | 4 | 5 | 6 = 2,
56
+ ): 'h2' | 'h3' | 'h4' | 'h5' | 'h6' {
57
+ const n = Math.round(Number(level));
58
+ const clamped = Number.isFinite(n) && n >= 2 && n <= 6 ? n : fallback;
59
+ return `h${clamped}` as 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
60
+ }
61
+
62
+ /** One run of inline text, optionally a link. */
63
+ export interface Run {
64
+ text: string;
65
+ href?: string;
66
+ }
67
+
68
+ /**
69
+ * Plain text with `[label](url)` links, as runs a template renders with no `set:html`.
70
+ *
71
+ * Repeater sub-fields are scalar strings, so a card's body cannot hold a link node; this is
72
+ * the smallest syntax that lets an editor write one. Every `href` goes through
73
+ * {@link safeHref}, and an unsafe one degrades to its label as plain text. Anything else —
74
+ * including HTML — stays literal text, which the template escapes.
75
+ */
76
+ export function linkRuns(input: unknown): Run[] {
77
+ const source = text(input);
78
+ const runs: Run[] = [];
79
+ let last = 0;
80
+ for (const m of source.matchAll(/\[([^\]]+)\]\(([^)\s]+)\)/g)) {
81
+ if (m.index > last) runs.push({ text: source.slice(last, m.index) });
82
+ const href = safeHref(m[2]);
83
+ runs.push(href ? { text: m[1]!, href } : { text: m[1]! });
84
+ last = m.index + m[0].length;
85
+ }
86
+ if (last < source.length) runs.push({ text: source.slice(last) });
87
+ return runs;
88
+ }
89
+
90
+ /**
91
+ * A heading with accent marks: `Two services. *One team.*` → the starred run is `accent`.
92
+ * Unbalanced stars stay literal.
93
+ */
94
+ export function accentRuns(input: unknown): { text: string; accent: boolean }[] {
95
+ const source = text(input);
96
+ const runs: { text: string; accent: boolean }[] = [];
97
+ let last = 0;
98
+ for (const m of source.matchAll(/\*([^*]+)\*/g)) {
99
+ if (m.index > last) runs.push({ text: source.slice(last, m.index), accent: false });
100
+ runs.push({ text: m[1]!, accent: true });
101
+ last = m.index + m[0].length;
102
+ }
103
+ if (last < source.length) runs.push({ text: source.slice(last), accent: false });
104
+ return runs;
105
+ }
106
+
107
+ /** Non-empty trimmed lines of a multiline field (a checklist, a feature list). */
108
+ export function lines(input: unknown): string[] {
109
+ return text(input)
110
+ .split('\n')
111
+ .map((l) => l.trim())
112
+ .filter(Boolean);
113
+ }
114
+
115
+ /**
116
+ * `Term | Value` lines → pairs. A line with no `|` is a term with an empty value, so a typo
117
+ * loses nothing visible.
118
+ */
119
+ export function pairs(input: unknown): { term: string; value: string }[] {
120
+ return lines(input).map((line) => {
121
+ const i = line.indexOf('|');
122
+ return i < 0
123
+ ? { term: line, value: '' }
124
+ : { term: line.slice(0, i).trim(), value: line.slice(i + 1).trim() };
125
+ });
126
+ }
@@ -2,13 +2,33 @@
2
2
  * componentsEntry for the vitops EmDash plugin. EmDash merges this map into
3
3
  * <PortableText> automatically — the export name MUST be `blockComponents`
4
4
  * and keys MUST match the block `type`s declared in ../blocks.ts.
5
+ *
6
+ * `blockTypeComponents` is the OTHER map: renderers for the schema-level block TYPES in
7
+ * `../block-types.ts` (a `blocks` field entry, keyed by slug), for a site to pass to
8
+ * `defineBlockComponents` / `<Blocks components={…}>`. EmDash does not merge it.
5
9
  */
10
+ import Accordion from './blocks/Accordion.astro';
6
11
  import ActionLink from './blocks/ActionLink.astro';
7
12
  import Banner from './blocks/Banner.astro';
13
+ import Callout from './blocks/Callout.astro';
14
+ import Cards from './blocks/Cards.astro';
8
15
  import Carousel from './blocks/Carousel.astro';
9
16
  import CopyButton from './blocks/CopyButton.astro';
10
17
  import Details from './blocks/Details.astro';
18
+ import Embed from './blocks/Embed.astro';
19
+ import Entries from './blocks/Entries.astro';
20
+ import Form from './blocks/Form.astro';
21
+ import Gallery from './blocks/Gallery.astro';
22
+ import Hero from './blocks/Hero.astro';
11
23
  import ImageCompare from './blocks/ImageCompare.astro';
24
+ import Logos from './blocks/Logos.astro';
25
+ import Marquee from './blocks/Marquee.astro';
26
+ import Nav from './blocks/Nav.astro';
27
+ import Pricing from './blocks/Pricing.astro';
28
+ import Reviews from './blocks/Reviews.astro';
29
+ import Section from './blocks/Section.astro';
30
+ import Stats from './blocks/Stats.astro';
31
+ import Steps from './blocks/Steps.astro';
12
32
 
13
33
  export const blockComponents = {
14
34
  'vitops.actionLink': ActionLink,
@@ -17,4 +37,24 @@ export const blockComponents = {
17
37
  'vitops.banner': Banner,
18
38
  'vitops.details': Details,
19
39
  'vitops.carousel': Carousel,
40
+ 'vitops.gallery': Gallery,
41
+ 'vitops.cards': Cards,
42
+ 'vitops.accordion': Accordion,
43
+ 'vitops.hero': Hero,
44
+ 'vitops.marquee': Marquee,
45
+ 'vitops.logos': Logos,
46
+ 'vitops.steps': Steps,
47
+ 'vitops.stats': Stats,
48
+ 'vitops.callout': Callout,
49
+ 'vitops.entries': Entries,
50
+ 'vitops.pricing': Pricing,
51
+ 'vitops.embed': Embed,
52
+ 'vitops.form': Form,
53
+ 'vitops.reviews': Reviews,
54
+ 'vitops.nav': Nav,
55
+ };
56
+
57
+ /** Keyed by block-type slug (see `SECTION_SLUG`), not by Portable Text `_type`. */
58
+ export const blockTypeComponents = {
59
+ vitops_section: Section,
20
60
  };
@@ -1,7 +0,0 @@
1
- /**
2
- * `@getvitops/core/editor` is a side-effect-only browser bundle (registers
3
- * `<wc-theme-editor>` via `customElements.define`) with no exports and, on
4
- * purpose, no published `.d.ts` — see that package's `exports` map. This is
5
- * the ambient shim so `import('@getvitops/core/editor')` type-checks here.
6
- */
7
- declare module '@getvitops/core/editor' {}