@tideworthy/tidysites-platform 0.10.15 → 0.11.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 (48) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +13 -0
  3. package/bin/tidysites-seed.mjs +51 -0
  4. package/package.json +14 -2
  5. package/src/sections/Photo.astro +18 -0
  6. package/src/sections/Sections.astro +42 -0
  7. package/src/sections/defaults/_Head.astro +13 -0
  8. package/src/sections/defaults/areas.astro +20 -0
  9. package/src/sections/defaults/badges.astro +17 -0
  10. package/src/sections/defaults/brands.astro +14 -0
  11. package/src/sections/defaults/case_studies.astro +16 -0
  12. package/src/sections/defaults/cases.astro +22 -0
  13. package/src/sections/defaults/clients.astro +14 -0
  14. package/src/sections/defaults/conditions.astro +13 -0
  15. package/src/sections/defaults/contact.astro +46 -0
  16. package/src/sections/defaults/cta.astro +16 -0
  17. package/src/sections/defaults/custom.astro +16 -0
  18. package/src/sections/defaults/downloads.astro +14 -0
  19. package/src/sections/defaults/faq.astro +20 -0
  20. package/src/sections/defaults/feature.astro +36 -0
  21. package/src/sections/defaults/financing.astro +17 -0
  22. package/src/sections/defaults/gallery.astro +14 -0
  23. package/src/sections/defaults/hero.astro +66 -0
  24. package/src/sections/defaults/insurance.astro +22 -0
  25. package/src/sections/defaults/intro.astro +11 -0
  26. package/src/sections/defaults/location.astro +32 -0
  27. package/src/sections/defaults/locations.astro +14 -0
  28. package/src/sections/defaults/offer.astro +18 -0
  29. package/src/sections/defaults/options.astro +12 -0
  30. package/src/sections/defaults/plans.astro +16 -0
  31. package/src/sections/defaults/policies.astro +13 -0
  32. package/src/sections/defaults/posts.astro +14 -0
  33. package/src/sections/defaults/products.astro +17 -0
  34. package/src/sections/defaults/related.astro +17 -0
  35. package/src/sections/defaults/reviews.astro +44 -0
  36. package/src/sections/defaults/services.astro +49 -0
  37. package/src/sections/defaults/signs.astro +12 -0
  38. package/src/sections/defaults/stats.astro +13 -0
  39. package/src/sections/defaults/steps.astro +32 -0
  40. package/src/sections/defaults/team.astro +42 -0
  41. package/src/sections/defaults/technologies.astro +13 -0
  42. package/src/sections/defaults/video.astro +15 -0
  43. package/src/sections/defaults.css +112 -0
  44. package/src/sections/lib.ts +157 -0
  45. package/src/template/index.ts +75 -0
  46. package/src/version.ts +1 -1
  47. package/template/README.md +33 -0
  48. package/template/structure.json +4224 -0
@@ -0,0 +1,157 @@
1
+ /** Helpers every section component shares: reading a stored block, images, lists, and the business record. */
2
+ import { getEmDashCollection, getSiteSettings } from "emdash";
3
+
4
+ export type Node = Record<string, unknown>;
5
+
6
+ /** A section component receives the stored block as `value`; a Portable Text renderer passes `node`; accept both or the props themselves. */
7
+ export function nodeOf(props: Record<string, unknown>): Node {
8
+ return (props.value as Node | undefined) ?? (props.node as Node | undefined) ?? props;
9
+ }
10
+ export const str = (v: unknown, fallback = ""): string => (typeof v === "string" ? v : fallback);
11
+ export const bool = (v: unknown, fallback = false): boolean => (typeof v === "boolean" ? v : typeof v === "number" ? v !== 0 : fallback);
12
+ export const int = (v: unknown, fallback: number): number => (typeof v === "number" && Number.isFinite(v) ? Math.floor(v) : fallback);
13
+ export const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : typeof v === "string" && v.trim() && Number.isFinite(Number(v)) ? Number(v) : null);
14
+ export const lines = (v: unknown): string[] => str(v).split("\n").map((s) => s.trim()).filter(Boolean);
15
+ export const list = <T = Record<string, unknown>>(v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : []);
16
+ export const slugify = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
17
+
18
+ /** An image field's value: a media value, or a plain URL left from an older site. */
19
+ export type Pic = { image: { id?: string; src?: string; alt?: string; width?: number; height?: number } | null; url: string | null };
20
+ export function pic(v: unknown): Pic {
21
+ if (typeof v === "string") return { image: null, url: v || null };
22
+ if (v && typeof v === "object") {
23
+ const o = v as { id?: string; src?: string; url?: string; alt?: string; width?: number; height?: number; meta?: { storageKey?: string } };
24
+ if (o.id || o.src) return { image: o, url: null };
25
+ const url = o.url ?? (o.meta?.storageKey ? `/_emdash/api/media/file/${o.meta.storageKey}` : null);
26
+ return { image: null, url };
27
+ }
28
+ return { image: null, url: null };
29
+ }
30
+ export const hasPic = (v: unknown): boolean => { const p = pic(v); return Boolean(p.image || p.url); };
31
+
32
+ /** "Label: value" lines into pairs. */
33
+ export function facts(v: unknown): Array<[string, string]> {
34
+ return lines(v).map((l) => { const i = l.indexOf(":"); return i > 0 ? [l.slice(0, i).trim(), l.slice(i + 1).trim()] : ["", l]; });
35
+ }
36
+ /** "Title|Text" lines into steps. */
37
+ export function steps(v: unknown): Array<{ title: string; text: string }> {
38
+ return lines(v).map((l) => { const i = l.indexOf("|"); return i > 0 ? { title: l.slice(0, i).trim(), text: l.slice(i + 1).trim() } : { title: l, text: "" }; });
39
+ }
40
+ export function telHref(phone: string): string {
41
+ const digits = phone.replace(/[^\d+]/g, "");
42
+ if (!digits) return "";
43
+ return `tel:${digits.startsWith("+") ? digits : digits.length === 10 ? `+1${digits}` : digits}`;
44
+ }
45
+
46
+ /** An entry from a collection read, as the sections see it. */
47
+ export interface Entry { id: string; slug?: string | null; data: Record<string, unknown> }
48
+ type Query = { status?: "published" | "draft"; limit?: number; orderBy?: Record<string, "asc" | "desc">; where?: Record<string, string> };
49
+
50
+ /** Published entries of a collection, sorted by `sort` when the collection has it; empty when the collection is missing on an older site. */
51
+ export async function entries(collection: string, q: Query = {}): Promise<Entry[]> {
52
+ try {
53
+ const r = await getEmDashCollection(collection, { status: "published", orderBy: { sort: "asc" }, ...q } as never);
54
+ return ((r as { entries?: Entry[] }).entries ?? []) as Entry[];
55
+ } catch {
56
+ return [];
57
+ }
58
+ }
59
+ /** Entries that pass a where filter plus an in-memory predicate (EmDash filters take strings only). */
60
+ export async function entriesWhere(collection: string, where: Record<string, string>, q: Query = {}): Promise<Entry[]> {
61
+ return entries(collection, Object.keys(where).length ? { ...q, where } : q);
62
+ }
63
+ export const field = (e: Entry, k: string): string => str(e.data[k]);
64
+
65
+ /** The business record, normalized to what sections and layouts read. Field names follow the template; older pest-era names are read too. */
66
+ export interface Business {
67
+ id: string | null;
68
+ title: string; tagline: string;
69
+ phone: string; phoneHref: string; phoneText: string; phoneTextHref: string; fax: string; email: string;
70
+ address: string; city: string; region: string; postal: string; country: string; hideAddress: boolean; isServiceArea: boolean;
71
+ hours: string; hoursNote: string;
72
+ foundedYear: number | null; yearsLine: string; sinceLine: string | null;
73
+ rating: number | null; reviewCount: number | null; ratingSource: string; reviewsUrl: string;
74
+ bookingUrl: string; bookingLabel: string; portalUrl: string; formsUrl: string; storeUrl: string; storeLabel: string; ctaLabel: string;
75
+ languages: string; newPatientsLine: string; neighbourhoods: string; serviceAreaLine: string; sameDayLine: string; emergencyLine: string;
76
+ insuranceLine: string; governmentPrograms: string; hsaFsaLine: string; licenseLine: string; insuredLine: string; warrantyLine: string; accessibilityLine: string; parkingLine: string; directionsLine: string;
77
+ social: Array<{ network: string; url: string }>;
78
+ brandPrimary: string; brandAccent: string; fontDisplay: string; fontBody: string;
79
+ logo: { src: string; alt?: string; width?: number; height?: number } | null;
80
+ ga4Id: string; gtmId: string; customCss: string;
81
+ raw: Record<string, unknown>;
82
+ }
83
+ const NETWORKS = ["facebook", "instagram", "google", "youtube", "linkedin", "tiktok", "yelp"];
84
+ function mediaSrc(v: unknown): Business["logo"] {
85
+ const p = pic(v);
86
+ if (p.image?.src) return { src: p.image.src, alt: p.image.alt, width: p.image.width, height: p.image.height };
87
+ if (p.image && (p.image as { meta?: { storageKey?: string } }).meta?.storageKey) return { src: `/_emdash/api/media/file/${(p.image as { meta: { storageKey: string } }).meta.storageKey}`, alt: p.image.alt };
88
+ if (p.url) return { src: p.url };
89
+ return null;
90
+ }
91
+ async function readBusiness(): Promise<Business> {
92
+ let d: Record<string, unknown> = {}; let id: string | null = null; let siteTitle = "";
93
+ try { const s = (await getSiteSettings()) as { title?: string } | null; siteTitle = s?.title ?? ""; } catch { /* no settings yet */ }
94
+ try {
95
+ const r = await getEmDashCollection("business", { status: "published", limit: 1 } as never);
96
+ const e = ((r as { entries?: Entry[] }).entries ?? [])[0];
97
+ if (e) { d = e.data; id = e.id; }
98
+ } catch { /* collection missing on an older site */ }
99
+ const s = (k: string, alt?: string) => str(d[k]) || (alt ? str(d[alt]) : "");
100
+ const phone = s("phone"); const phoneText = s("phone_text");
101
+ const founded = num(d.founded_year);
102
+ const city = s("city");
103
+ const primary = s("brand_primary") || "#1e5a46"; const accent = s("brand_accent") || "#e8b23a";
104
+ return {
105
+ id, title: s("title") || siteTitle || "Our business", tagline: s("tagline"),
106
+ phone, phoneHref: telHref(phone), phoneText, phoneTextHref: phoneText ? `sms:${phoneText.replace(/[^\d+]/g, "")}` : "", fax: s("fax"), email: s("email"),
107
+ address: s("address"), city, region: s("region"), postal: s("postal"), country: s("country"), hideAddress: bool(d.hide_address), isServiceArea: bool(d.is_service_area_business),
108
+ hours: s("hours"), hoursNote: s("hours_note"),
109
+ foundedYear: founded, yearsLine: s("years_line"), sinceLine: city && founded ? `${city} since ${founded}` : city || null,
110
+ rating: num(d.rating), reviewCount: num(d.review_count), ratingSource: s("rating_source") || "Google", reviewsUrl: s("reviews_url"),
111
+ bookingUrl: s("booking_url"), bookingLabel: s("booking_label") || "Book online", portalUrl: s("portal_url"), formsUrl: s("forms_url"), storeUrl: s("store_url"), storeLabel: s("store_label") || "Shop", ctaLabel: s("cta_label") || "Get in touch",
112
+ languages: s("languages"), newPatientsLine: s("new_patients_line"), neighbourhoods: s("neighbourhoods"), serviceAreaLine: s("service_area_line"), sameDayLine: s("same_day_line", "same_day_note"), emergencyLine: s("emergency_line"),
113
+ insuranceLine: s("insurance_line"), governmentPrograms: s("government_programs"), hsaFsaLine: s("hsa_fsa_line"), licenseLine: s("license_line"), insuredLine: s("insured_line"), warrantyLine: s("warranty_line"), accessibilityLine: s("accessibility_line"), parkingLine: s("parking_line"), directionsLine: s("directions_line"),
114
+ social: NETWORKS.map((n) => ({ network: n, url: s(n) })).filter((x) => x.url),
115
+ brandPrimary: primary, brandAccent: accent, fontDisplay: s("font_display") || "Bricolage Grotesque", fontBody: s("font_body") || "Public Sans",
116
+ logo: mediaSrc(d.logo), ga4Id: s("ga4_id"), gtmId: s("gtm_id"), customCss: s("custom_css"), raw: d,
117
+ };
118
+ }
119
+ const memo = new WeakMap<Request, Promise<Business>>();
120
+ /** One read per request, however many sections ask. */
121
+ export function getBusiness(request: Request): Promise<Business> {
122
+ let p = memo.get(request);
123
+ if (!p) { p = readBusiness(); memo.set(request, p); }
124
+ return p;
125
+ }
126
+ /** Text contrast for a background colour. */
127
+ export function inkFor(hex: string): string {
128
+ const m = /^#?([0-9a-f]{6})$/i.exec(hex); if (!m) return "#ffffff";
129
+ const n = parseInt(m[1]!, 16); const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
130
+ return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6 ? "#111111" : "#ffffff";
131
+ }
132
+ /** Inline style for <html>: the brand tokens from the business record. */
133
+ export function brandStyle(b: Business): string {
134
+ const font = (name: string, fallback: string) => `"${name.replace(/"/g, "")}", ${fallback}`;
135
+ return [`--brand-primary:${b.brandPrimary}`, `--brand-primary-ink:${inkFor(b.brandPrimary)}`, `--brand-accent:${b.brandAccent}`, `--brand-accent-ink:${inkFor(b.brandAccent)}`, `--brand-font-display:${font(b.fontDisplay, "Arial, sans-serif")}`, `--brand-font-body:${font(b.fontBody, '"Helvetica Neue", Arial, sans-serif')}`].join(";");
136
+ }
137
+ /** Google Fonts stylesheet for the two brand families. */
138
+ export function fontsHref(b: Business): string {
139
+ const fam = (name: string, axes: string) => `family=${encodeURIComponent(name).replace(/%20/g, "+")}:${axes}`;
140
+ return `https://fonts.googleapis.com/css2?${fam(b.fontDisplay, "wght@600;700;800")}&${fam(b.fontBody, "wght@400;500;600;700")}&display=swap`;
141
+ }
142
+ /** The one line of address a section prints. */
143
+ export function addressLine(b: { address: string; city: string; region: string; postal: string; hideAddress?: boolean }): string {
144
+ if (b.hideAddress) return [b.city, b.region].filter(Boolean).join(", ");
145
+ return [b.address, [b.city, b.region].filter(Boolean).join(", "), b.postal].filter(Boolean).join(", ");
146
+ }
147
+ /** Hours rows of a location record (hours_mon..hours_sun), grouped when identical days follow each other. */
148
+ export const DAYS: Array<[string, string]> = [["hours_mon", "Monday"], ["hours_tue", "Tuesday"], ["hours_wed", "Wednesday"], ["hours_thu", "Thursday"], ["hours_fri", "Friday"], ["hours_sat", "Saturday"], ["hours_sun", "Sunday"]];
149
+ export function hoursRows(d: Record<string, unknown>): Array<{ day: string; value: string }> {
150
+ return DAYS.map(([k, day]) => ({ day, value: str(d[k]) })).filter((r) => r.value);
151
+ }
152
+ export function hoursGrouped(d: Record<string, unknown>): Array<{ days: string; value: string }> {
153
+ const rows = hoursRows(d); const out: Array<{ days: string[]; value: string }> = [];
154
+ for (const r of rows) { const last = out[out.length - 1]; if (last && last.value === r.value) last.days.push(r.day); else out.push({ days: [r.day], value: r.value }); }
155
+ return out.map((g) => ({ days: g.days.length > 2 ? `${g.days[0]} to ${g.days[g.days.length - 1]}` : g.days.join(" and "), value: g.value }));
156
+ }
157
+ export const todayKey = (): string => DAYS[(new Date().getDay() + 6) % 7]![0];
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The Tidysites template: the one data shape every theme renders. The JSON
3
+ * at /template/structure.json is the source; this module gives it types and
4
+ * the merge a theme's seed goes through so a theme never carries a schema of
5
+ * its own (see template/README.md).
6
+ */
7
+ import structure from "../../template/structure.json";
8
+
9
+ export interface TemplateField {
10
+ slug: string;
11
+ label: string;
12
+ type: string;
13
+ required?: boolean;
14
+ validation?: Record<string, unknown>;
15
+ defaultValue?: unknown;
16
+ [k: string]: unknown;
17
+ }
18
+ export interface TemplateCollection {
19
+ slug: string;
20
+ label: string;
21
+ labelSingular: string;
22
+ supports?: string[];
23
+ fields: TemplateField[];
24
+ [k: string]: unknown;
25
+ }
26
+ export interface SectionType {
27
+ slug: string;
28
+ label: string;
29
+ description?: string;
30
+ category?: string;
31
+ currentVersion: number;
32
+ versions: Array<{ version: number; fields: TemplateField[] }>;
33
+ }
34
+ export interface TemplateMenu {
35
+ name: string;
36
+ label: string;
37
+ items: Array<Record<string, unknown>>;
38
+ }
39
+ export interface TemplateStructure {
40
+ $schema?: string;
41
+ version: string;
42
+ meta?: Record<string, unknown>;
43
+ blockTypes: SectionType[];
44
+ collections: TemplateCollection[];
45
+ menus?: TemplateMenu[];
46
+ relations?: unknown;
47
+ }
48
+
49
+ export const template = structure as unknown as TemplateStructure;
50
+ /** Every section type a page may hold, in the template's order. */
51
+ export const sectionTypes: SectionType[] = template.blockTypes;
52
+ export const collections: TemplateCollection[] = template.collections;
53
+ /** The section type slugs, for a theme that wants to check coverage. */
54
+ export const SECTION_TYPES: string[] = sectionTypes.map((b) => b.slug);
55
+
56
+ export function collection(slug: string): TemplateCollection | undefined {
57
+ return collections.find((c) => c.slug === slug);
58
+ }
59
+ export function sectionType(slug: string): SectionType | undefined {
60
+ return sectionTypes.find((b) => b.slug === slug);
61
+ }
62
+
63
+ /**
64
+ * A theme's seed with the template's structure in place of its own: the
65
+ * collections, block types and relations come from the template; the menus,
66
+ * settings, meta and content stay the theme's. A theme seed is content and
67
+ * menus; everything structural is this package's.
68
+ */
69
+ export function mergeStructure<T extends Record<string, unknown>>(seed: T): T & Pick<TemplateStructure, "version" | "blockTypes" | "collections"> {
70
+ const { blockTypes: _b, collections: _c, relations: _r, ...rest } = seed as Record<string, unknown>;
71
+ const out: Record<string, unknown> = { ...rest, version: template.version, blockTypes: template.blockTypes, collections: template.collections };
72
+ if (template.relations !== undefined) out.relations = template.relations;
73
+ if (!out.menus && template.menus) out.menus = template.menus;
74
+ return out as T & Pick<TemplateStructure, "version" | "blockTypes" | "collections">;
75
+ }
package/src/version.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  /** Kept in step with package.json by the release checklist in MAINTAINING.md. */
2
- export const VERSION = "0.10.15";
2
+ export const VERSION = "0.11.0";
3
3
  export const PLUGIN_ID = "tidy-platform";
@@ -0,0 +1,33 @@
1
+ # The Tidysites template
2
+
3
+ One data shape, rendered by every theme. `structure.json` is the structure half of an EmDash seed: 24 collections, 35 section types, the two menus. No content, no design. It validates with EmDash's `validateSeed` (emdash 1.0.1).
4
+
5
+ ## The model
6
+
7
+ - **There is one template.** Every site has every collection and every section type available. A dentist, a roofer, a bike shop with a repair bench and a marketing agency run the same schema; what differs is which records they fill and which sections their pages use.
8
+ - **Everything is optional.** A record needs its name (a review needs its quote, a FAQ its answer, a policy its text). Every other field is optional, because that is what real sites showed: a date on one review in eleven, a price on no service page, bios on cards for some and on profile pages for others.
9
+ - **The theme is the only choice.** A person picks a look and launches it. The theme decides which sections its pages use and how each renders. There are no profiles, presets or industry variants of the schema.
10
+ - **A theme renders; it never adds.** A theme may render any field or section type here and may ignore any. It adds no fields and no section types of its own. When a theme needs something this template lacks, the template grows, additively, and every theme may render the new piece or ignore it. That rule is what lets a site switch themes without touching its content, and what lets a site owner's assistant add products, service areas or case studies to a site whose theme never showed them: the data has a home already.
11
+ - **The plugin owes a default rendering of every section type** so that a section a theme never shipped still renders when an owner adds it, and the theme's own component takes over when it has one. Not built yet; it is the next piece.
12
+ - **Live sites take template changes through the schema apply** (`POST /_tidy/schema`, run after every staging deploy). Definitions are updated or created, nothing is deleted. Retiring a field is a separate, deliberate step once no live site holds data in it.
13
+
14
+ ## What is in it
15
+
16
+ Business and place: `business` (the one record every page reads: name, phones with purposes, address and whether to show it, hours in one line, booking, portal, forms and store links, languages, rating and count, insurance and programs lines, licence, insured and warranty lines, accessibility, parking, directions, emergency line, service-area line, social, brand tokens, analytics ids), `locations` (per-day hours, parking, directions, transit, map, booking per location; exists even for one location so growth is never a migration), `service_areas` (places served, with neighbourhoods, coordinates, distance, home base).
17
+
18
+ What they do: `service_categories`, `services` (with a call-to-action kind, price from, duration, free consultation, in-season flag, providers through reviews and cases), `conditions` (what customers search for, pointing at the service that treats it), `technologies`, `products` (name, category, brand, price, images, variants, stock, buy or enquire link), `brands`.
19
+
20
+ Who they are: `people` (role, group, credentials, licence, two bio lengths, education, memberships, awards, languages, pronouns, video, start year, status note, owner flag, own-page flag), `badges` (associations, certifications, awards and seals with issuer and year), `clients` (logo wall), `case_studies` (situation, approach, outcome, three result numbers, quote).
21
+
22
+ Proof: `reviews` (quote, display name, rating, source, date, photo, service, service area, provider, video, featured), `cases` (before and after with problem and treatment).
23
+
24
+ Money and admin: `plans` (memberships, packages, tiers, banked credit), `insurers` (network status, note, which services), `financing_providers`, `offers` (with how to claim and an end date), `downloads` (intake, consent, pre-op, post-op), `policies`.
25
+
26
+ Pages and words: `pages`, `posts`, `faqs`, and a `sections` field on pages, services, service categories, service areas and case studies that holds any of the 35 section types.
27
+
28
+ Section types: hero (five layouts incl. a picker), intro, services (five layouts), feature or why choose us (with bullets, steps, photo side), steps, team (four layouts), reviews (four), cases (three), faq, stats, badges, insurance (three), financing, plans, contact or request form (three, each question a switch), location (per-day or grouped hours), locations, cta (three), gallery (three), video, technologies, brands, offer, conditions, downloads, policies, posts, areas (four), products (three), case_studies (three), clients (three), signs, options, related, custom (HTML, CSS and script in an isolated frame).
29
+
30
+ ## Where the shape came from
31
+
32
+ Read by hand, with every claim tied to the URL it was seen on: six sites in a pilot (three roofers, three dentists), then eleven local service sites (seven dental, plus chiropractic, med spa, optometry and physiotherapy), plus the pest-control template's own collections for the service-area side. The evidence tables below are from the local service read; the counts are out of eleven unless said. Service-area, shop and agency evidence is thinner (the pilot's roofers and the pest template) and those parts of the schema will be revised as those kinds are read.
33
+