@getvitops/utils 8.2.0 → 8.3.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.
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":["OKLCH_Space","sRGB_Space","P3_Space"],"sources":["../../src/color/colors.ts","../../src/color/color-harmony.ts"],"sourcesContent":["import Color from 'colorjs.io';\nimport {\n OKLCH as OKLCH_Space,\n sRGB as sRGB_Space,\n P3 as P3_Space,\n ColorSpace,\n to,\n toGamutCSS,\n clone,\n serialize,\n type PlainColorObject,\n} from 'colorjs.io/fn';\n\nColorSpace.register(OKLCH_Space);\nColorSpace.register(sRGB_Space);\nColorSpace.register(P3_Space);\n\nexport interface RgbResult {\n r: number;\n g: number;\n b: number;\n inGamut: boolean;\n /** Max channel overshoot beyond [0,1] before clamping. 0 when in gamut. */\n gamutDistance: number;\n}\n\n/**\n * Convert OKLCh to sRGB, returning 0-255 clamped values with gamut flag.\n * Fast path for per-pixel canvas rendering (no gamut mapping, just clamp).\n */\nexport function oklchToSrgb(l: number, c: number, h: number): RgbResult {\n const col: PlainColorObject = { space: OKLCH_Space, coords: [l, c, h], alpha: 1 };\n const rgb = to(col, sRGB_Space);\n const [r, g, b] = rgb.coords;\n const inGamut = r >= 0 && r <= 1 && g >= 0 && g <= 1 && b >= 0 && b <= 1;\n const gamutDistance = inGamut ? 0 : Math.max(-r, r - 1, -g, g - 1, -b, b - 1, 0);\n return {\n r: Math.max(0, Math.min(255, Math.round(r * 255))),\n g: Math.max(0, Math.min(255, Math.round(g * 255))),\n b: Math.max(0, Math.min(255, Math.round(b * 255))),\n inGamut,\n gamutDistance,\n };\n}\n\n/**\n * Convert OKLCh to Display P3, returning 0-255 clamped values with gamut flag.\n * Fast path for per-pixel canvas rendering on P3 displays.\n */\nexport function oklchToP3(l: number, c: number, h: number): RgbResult {\n const col: PlainColorObject = { space: OKLCH_Space, coords: [l, c, h], alpha: 1 };\n const rgb = to(col, P3_Space);\n const [r, g, b] = rgb.coords;\n const inGamut = r >= 0 && r <= 1 && g >= 0 && g <= 1 && b >= 0 && b <= 1;\n const gamutDistance = inGamut ? 0 : Math.max(-r, r - 1, -g, g - 1, -b, b - 1, 0);\n return {\n r: Math.max(0, Math.min(255, Math.round(r * 255))),\n g: Math.max(0, Math.min(255, Math.round(g * 255))),\n b: Math.max(0, Math.min(255, Math.round(b * 255))),\n inGamut,\n gamutDistance,\n };\n}\n\n/**\n * Gamut-map an OKLCh color using the CSS gamut mapping algorithm, return hex.\n * Slower than oklchToSrgb/oklchToP3 (uses iterative reduction), use for single colors only.\n */\nexport function gamutMapOklchToHex(\n l: number,\n c: number,\n h: number,\n space: 'srgb' | 'p3' = 'srgb',\n): string {\n const col: PlainColorObject = { space: OKLCH_Space, coords: [l, c, h], alpha: 1 };\n const targetSpace = space === 'p3' ? P3_Space : sRGB_Space;\n const mapped = toGamutCSS(clone(col), { space: targetSpace });\n const rgb = to(mapped, sRGB_Space);\n return serialize(rgb, { format: 'hex' });\n}\n\n// Standard 11-step scale - will be transposed based on input anchors\nexport const STEP_NAMES = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const;\nexport type StepName = (typeof STEP_NAMES)[number];\n\nexport const COLOR_GROUPS = [\n 'neutral',\n 'brand',\n 'primary',\n 'accent',\n 'complement',\n 'success',\n 'warning',\n 'error',\n 'info',\n] as const;\nexport type ColorGroup = (typeof COLOR_GROUPS)[number];\n\n/**\n * Convert hex color to OKLCH components\n */\nexport function hexToOklch(hex: string): { l: number; c: number; h: number } {\n const color = new Color(hex);\n const oklch = color.to('oklch');\n return {\n l: oklch.coords[0],\n c: oklch.coords[1],\n h: oklch.coords[2] || 0,\n };\n}\n\nexport function oklchToHex(oklch: { l: number; c: number; h: number }): string {\n const color = new Color('oklch', [oklch.l, oklch.c, oklch.h]);\n const srgb = color.to('srgb');\n if (!srgb.inGamut()) {\n srgb.toGamut({ method: 'css' });\n }\n return srgb.toString({ format: 'hex' });\n}\n\n/**\n * Convert OKLCH CSS string to hex color\n * @example oklchStringToHex(\"oklch(50% 0.15 270)\") => \"#8b5cf6\"\n */\nexport function oklchStringToHex(oklchString: string): string {\n try {\n const color = new Color(oklchString);\n const srgb = color.to('srgb');\n if (!srgb.inGamut()) {\n srgb.toGamut({ method: 'css' });\n }\n return srgb.toString({ format: 'hex' });\n } catch {\n return '#808080';\n }\n}\n\n/**\n * Format a color name by separating compound words\n * Examples: \"mediumseagreen\" -> \"Medium Sea Green\", \"darkblue\" -> \"Dark Blue\"\n */\nexport function formatColorName(name: string): string {\n if (!name) return 'Unknown';\n\n const specialCases: Record<string, string> = {\n aliceblue: 'Alice Blue',\n antiquewhite: 'Antique White',\n blanchedalmond: 'Blanched Almond',\n blueviolet: 'Blue Violet',\n burlywood: 'Burly Wood',\n cadetblue: 'Cadet Blue',\n cornflowerblue: 'Cornflower Blue',\n darkblue: 'Dark Blue',\n darkcyan: 'Dark Cyan',\n darkgoldenrod: 'Dark Goldenrod',\n darkgray: 'Dark Gray',\n darkgreen: 'Dark Green',\n darkgrey: 'Dark Grey',\n darkkhaki: 'Dark Khaki',\n darkmagenta: 'Dark Magenta',\n darkolivegreen: 'Dark Olive Green',\n darkorange: 'Dark Orange',\n darkorchid: 'Dark Orchid',\n darkred: 'Dark Red',\n darksalmon: 'Dark Salmon',\n darkseagreen: 'Dark Sea Green',\n darkslateblue: 'Dark Slate Blue',\n darkslategray: 'Dark Slate Gray',\n darkslategrey: 'Dark Slate Grey',\n darkturquoise: 'Dark Turquoise',\n darkviolet: 'Dark Violet',\n deeppink: 'Deep Pink',\n deepskyblue: 'Deep Sky Blue',\n dimgray: 'Dim Gray',\n dimgrey: 'Dim Grey',\n dodgerblue: 'Dodger Blue',\n floralwhite: 'Floral White',\n forestgreen: 'Forest Green',\n ghostwhite: 'Ghost White',\n greenyellow: 'Green Yellow',\n hotpink: 'Hot Pink',\n indianred: 'Indian Red',\n lavenderblush: 'Lavender Blush',\n lawngreen: 'Lawn Green',\n lemonchiffon: 'Lemon Chiffon',\n lightblue: 'Light Blue',\n lightcoral: 'Light Coral',\n lightcyan: 'Light Cyan',\n lightgoldenrodyellow: 'Light Goldenrod Yellow',\n lightgray: 'Light Gray',\n lightgreen: 'Light Green',\n lightgrey: 'Light Grey',\n lightpink: 'Light Pink',\n lightsalmon: 'Light Salmon',\n lightseagreen: 'Light Sea Green',\n lightskyblue: 'Light Sky Blue',\n lightslategray: 'Light Slate Gray',\n lightslategrey: 'Light Slate Grey',\n lightsteelblue: 'Light Steel Blue',\n lightyellow: 'Light Yellow',\n limegreen: 'Lime Green',\n mediumaquamarine: 'Medium Aquamarine',\n mediumblue: 'Medium Blue',\n mediumorchid: 'Medium Orchid',\n mediumpurple: 'Medium Purple',\n mediumseagreen: 'Medium Sea Green',\n mediumslateblue: 'Medium Slate Blue',\n mediumspringgreen: 'Medium Spring Green',\n mediumturquoise: 'Medium Turquoise',\n mediumvioletred: 'Medium Violet Red',\n midnightblue: 'Midnight Blue',\n mintcream: 'Mint Cream',\n mistyrose: 'Misty Rose',\n navajowhite: 'Navajo White',\n oldlace: 'Old Lace',\n olivedrab: 'Olive Drab',\n orangered: 'Orange Red',\n palegoldenrod: 'Pale Goldenrod',\n palegreen: 'Pale Green',\n paleturquoise: 'Pale Turquoise',\n palevioletred: 'Pale Violet Red',\n papayawhip: 'Papaya Whip',\n peachpuff: 'Peach Puff',\n powderblue: 'Powder Blue',\n rosybrown: 'Rosy Brown',\n royalblue: 'Royal Blue',\n saddlebrown: 'Saddle Brown',\n sandybrown: 'Sandy Brown',\n seagreen: 'Sea Green',\n skyblue: 'Sky Blue',\n slateblue: 'Slate Blue',\n slategray: 'Slate Gray',\n slategrey: 'Slate Grey',\n springgreen: 'Spring Green',\n steelblue: 'Steel Blue',\n whitesmoke: 'White Smoke',\n yellowgreen: 'Yellow Green',\n };\n\n const lowerName = name.toLowerCase();\n if (specialCases[lowerName]) {\n return specialCases[lowerName];\n }\n\n // Default: just capitalize first letter\n return name.charAt(0).toUpperCase() + name.slice(1);\n}\n\n/**\n * Get the closest CSS color name for a hex color\n */\nexport function getColorName(hex: string): string {\n try {\n const color = new Color(hex);\n const oklch = color.to('oklch');\n const hue = oklch.coords[2] || 0;\n\n if (hue < 30 || hue >= 330) return 'Red';\n if (hue < 60) return 'Orange';\n if (hue < 90) return 'Yellow';\n if (hue < 150) return 'Green';\n if (hue < 210) return 'Cyan';\n if (hue < 270) return 'Blue';\n if (hue < 330) return 'Purple';\n return 'Unknown';\n } catch {\n return 'Unknown';\n }\n}\n\n/**\n * Get color name with hue value\n */\nexport function getColorNameWithHue(hex: string): string {\n try {\n const color = new Color(hex);\n const oklch = color.to('oklch');\n const hue = oklch.coords[2] || 0;\n const roundedHue = Math.round(hue / 10) * 10;\n const name = getColorName(hex);\n return `${name} ${roundedHue}`;\n } catch {\n return 'Unknown';\n }\n}\n\n// Color harmony definitions\nexport const HARMONY_TYPES = [\n 'single',\n 'analogous',\n 'triad',\n 'complementary',\n 'split-complementary',\n 'square',\n 'compound',\n] as const;\nexport type HarmonyType = (typeof HARMONY_TYPES)[number];\n\nexport interface HarmonyDefinition {\n label: string;\n offsets: number[];\n primaryIndex: number;\n}\n\nexport const HARMONY_DEFINITIONS: Record<HarmonyType, HarmonyDefinition> = {\n single: {\n label: 'Single',\n offsets: [0],\n primaryIndex: 0,\n },\n analogous: {\n label: 'Analogous',\n offsets: [-30, 0, 30],\n primaryIndex: 1,\n },\n triad: {\n label: 'Triad',\n offsets: [0, 120, 240],\n primaryIndex: 0,\n },\n complementary: {\n label: 'Complementary',\n offsets: [0, 180],\n primaryIndex: 0,\n },\n 'split-complementary': {\n label: 'Split Complementary',\n offsets: [0, 150, 210],\n primaryIndex: 0,\n },\n square: {\n label: 'Square',\n offsets: [0, 90, 180, 270],\n primaryIndex: 0,\n },\n compound: {\n label: 'Compound',\n offsets: [-30, 0, 180, 210],\n primaryIndex: 1,\n },\n};\n\n/**\n * The shared lightness ladder every ramp is built on (lightest → darkest).\n *\n * Fixed, NOT seed-derived. Two hues at the same step have to read equally light,\n * otherwise one contrast table cannot serve every hue and a `muted` variant means\n * something different depending on which role you ask. Only chroma and hue vary\n * between ramps.\n *\n * The previous engine transposed this curve onto each seed, which let relative\n * luminance at step 300 range from 0.253 (rust) to 0.384 (amber) across the\n * shipped palette — the drift this replaces.\n *\n * Anchored steps are the one deliberate exception: see `generateOklchPalette`.\n */\nexport const LIGHTNESS_LADDER: Record<StepName, number> = {\n 50: 0.98,\n 100: 0.95,\n 200: 0.9,\n 300: 0.83,\n 400: 0.74,\n 500: 0.65,\n 600: 0.54,\n 700: 0.47,\n 800: 0.38,\n 900: 0.29,\n 950: 0.21,\n};\n\n/**\n * Chroma a ramp decays *towards* beyond its outermost anchors, so the near-white\n * and near-black ends keep a whisper of the hue instead of going flat grey. The\n * dark end carries slightly more because chroma reads weaker against black.\n *\n * It is a CEILING, not a target — `Math.min(ENDPOINT_CHROMA.x, anchor.c)`. That\n * distinction is the whole reason a neutral is authorable. The interpolation\n * factor reaches exactly 1 at steps 50 and 950 for every anchor position, so as a\n * target it made the endpoints these constants *unconditionally*: seeds at chroma\n * 0.001, 0.002, 0.05 and 0.2 all produced a byte-identical step 50. A brand\n * wanting plain white with grey panels had no seed that worked, and chroma 0 was\n * worst of all — colorjs returns NaN hue for a true achromatic colour, which\n * `hexToOklch` collapses to 0, so the \"neutral\" came out pink.\n *\n * As a ceiling, a low-chroma seed simply stays low and a chroma-0 seed stays 0 at\n * every unanchored step, which makes the NaN-hue collapse invisible rather than\n * something `hexToOklch` has to solve (its `|| 0` is load-bearing for\n * `getColorName`).\n */\nexport const ENDPOINT_CHROMA = { light: 0.008, dark: 0.015 } as const;\n\ninterface OklchColor {\n l: number;\n c: number;\n h: number;\n}\n\n/**\n * Parses an OKLCH CSS string into numerical components.\n * @param oklchString - e.g., 'oklch(50% 0.15 270)'\n * @returns Lightness (0-1), Chroma (0-~0.37), Hue (0-360)\n */\nexport function parseOklch(oklchString: string): OklchColor {\n const parts = oklchString.match(/oklch\\(([^ ]+) ([^ ]+) ([^ ]+)\\)/);\n if (!parts) throw new Error('Invalid OKLCH string format.');\n\n const [, lRaw = '', cRaw = '', hRaw = ''] = parts;\n const l = parseFloat(lRaw) / (lRaw.includes('%') ? 100 : 1);\n const c = parseFloat(cRaw);\n const h = parseFloat(hRaw);\n\n return { l, c, h };\n}\n\n/**\n * Linearly interpolates a value between a start and end point.\n */\nfunction lerp(start: number, end: number, factor: number): number {\n return start + factor * (end - start);\n}\n\n/**\n * Interpolate hue the short way round the wheel. A ramp anchored at 350° and 10°\n * spans 20°, not 340° — the naive lerp sent it the long way through cyan.\n */\nfunction lerpHue(start: number, end: number, factor: number): number {\n const delta = ((((end - start) % 360) + 540) % 360) - 180;\n return (start + delta * factor + 360) % 360;\n}\n\n/**\n * The ladder step whose lightness is closest to `lightness` — i.e. where a\n * supplied colour naturally belongs.\n */\nexport function assignStepByLightness(lightness: number): StepName {\n let closestStep: StepName = 500;\n let minDiff = Math.abs(lightness - LIGHTNESS_LADDER[500]);\n\n for (const step of STEP_NAMES) {\n const diff = Math.abs(lightness - LIGHTNESS_LADDER[step]);\n if (diff < minDiff) {\n minDiff = diff;\n closestStep = step;\n }\n }\n\n return closestStep;\n}\n\n/**\n * How far an anchored colour sits from its step's ladder lightness.\n *\n * The generator surfaces this as a warning: a large deviation is legal (the\n * caller asked for that exact colour) but it means the hue will read heavier or\n * lighter than its siblings at that step, which is worth knowing rather than\n * discovering later.\n */\nexport function ladderDeviation(step: StepName, lightness: number): number {\n return lightness - LIGHTNESS_LADDER[step];\n}\n\n/** Absolute chroma ceiling — beyond this nothing survives gamut mapping anyway. */\nconst MAX_CHROMA = 0.37;\n\n/**\n * Build a complete 11-step OKLCH ramp from one or more anchor colours.\n *\n * **Ladder-first with exact anchors.** Every anchor is reproduced *verbatim* at\n * its step; every other step takes its lightness from `LIGHTNESS_LADDER`, with\n * chroma and hue interpolated between the flanking anchors and decayed toward\n * `ENDPOINT_CHROMA` beyond the outermost ones.\n *\n * The consequence is the point: a client's brand hex survives byte-for-byte,\n * while the other ten steps stay directly comparable to every other hue. The\n * deviation from the ladder is bounded and *local to anchored steps* — unlike\n * the previous engine, which warped the whole ramp to fit its anchors so every\n * step drifted.\n *\n * Chroma may come out beyond the sRGB gamut here; that is deliberate.\n * `oklchStringToHex` gamut-maps with the CSS algorithm, which reduces chroma\n * while holding lightness, so the ladder survives the conversion.\n */\nexport function generateOklchPalette(\n input: string | string[] | Record<string, string>,\n): Record<StepName, string> {\n let anchors: Record<number, string> = {};\n\n if (typeof input === 'string') {\n anchors[assignStepByLightness(parseOklch(input).l)] = input;\n } else if (Array.isArray(input)) {\n for (const colorStr of input) {\n anchors[assignStepByLightness(parseOklch(colorStr).l)] = colorStr;\n }\n } else if (typeof input === 'object' && input !== null) {\n anchors = { ...input };\n } else {\n throw new Error(\n 'Input must be a color string, array of colors, or object with step assignments',\n );\n }\n\n const parsedAnchors: Record<number, OklchColor> = {};\n for (const [key, value] of Object.entries(anchors)) {\n parsedAnchors[Number(key)] = parseOklch(value);\n }\n\n const anchorSteps = Object.keys(parsedAnchors)\n .map(Number)\n .sort((a, b) => a - b) as StepName[];\n\n // Interpolate across step *index*, not the numeric label: the labels aren't\n // evenly spaced (50→100 is a smaller jump than 100→200), so lerping on them\n // would bunch the ramp up at the light end.\n const idx = (step: number) => STEP_NAMES.indexOf(step as StepName);\n const lastIdx = STEP_NAMES.length - 1;\n\n const palette: Record<number, string> = {};\n\n for (const step of STEP_NAMES) {\n // An anchor is the caller's exact colour. Emit it untouched.\n if (parsedAnchors[step]) {\n palette[step] = anchors[step] as string;\n continue;\n }\n\n // `lighter` is the nearest anchor with a smaller step (50 is lightest);\n // `darker` the nearest with a larger one.\n const lighter = [...anchorSteps].reverse().find((s) => s < step);\n const darker = anchorSteps.find((s) => s > step);\n\n let chroma: number;\n let hue: number;\n\n if (lighter !== undefined && darker !== undefined) {\n const a = parsedAnchors[lighter] as OklchColor;\n const b = parsedAnchors[darker] as OklchColor;\n const factor = (idx(step) - idx(lighter)) / (idx(darker) - idx(lighter));\n chroma = lerp(a.c, b.c, factor);\n hue = lerpHue(a.h, b.h, factor);\n } else if (lighter !== undefined) {\n // Past the darkest anchor — fade toward the tinted near-black end.\n const a = parsedAnchors[lighter] as OklchColor;\n const factor = (idx(step) - idx(lighter)) / (lastIdx - idx(lighter));\n chroma = lerp(a.c, Math.min(ENDPOINT_CHROMA.dark, a.c), factor);\n hue = a.h;\n } else if (darker !== undefined) {\n // Past the lightest anchor — fade toward the tinted near-white end.\n const b = parsedAnchors[darker] as OklchColor;\n const factor = (idx(darker) - idx(step)) / idx(darker);\n chroma = lerp(b.c, Math.min(ENDPOINT_CHROMA.light, b.c), factor);\n hue = b.h;\n } else {\n chroma = 0.1;\n hue = 0;\n }\n\n const lightness = LIGHTNESS_LADDER[step];\n const finalL = (lightness * 100).toFixed(2) + '%';\n const finalC = Math.min(chroma, MAX_CHROMA).toFixed(4);\n const finalH = hue.toFixed(2);\n\n palette[step] = `oklch(${finalL} ${finalC} ${finalH})`;\n }\n\n return palette;\n}\n\n// ── Contrast + endpoints (for the functional-token layer) ────────────────────\n\n/** APCA lightness-contrast (Lc) magnitude between a foreground and background. */\nexport function contrastLc(fg: string, bg: string): number {\n return Math.abs(new Color(bg).contrast(fg, 'APCA'));\n}\n\n/**\n * Pick a tinted near-white or near-black foreground for best contrast on `bg`\n * (a whisper of the background's hue mixed into off-white/off-black).\n */\nexport function pickOn(bg: string, hue?: number): string {\n const h = hue ?? hexToOklch(bg).h;\n const white = oklchToHex({ l: 0.98, c: 0.01, h });\n const black = oklchToHex({ l: 0.16, c: 0.02, h });\n return contrastLc(white, bg) >= contrastLc(black, bg) ? white : black;\n}\n\n/**\n * The tinted off-white / off-black endpoints for a hue — white/black with a\n * whisper of the hue mixed in. These are the text/background tints every ramp\n * needs (and the one exception strict brand kits allow).\n *\n * Derived from `LIGHTNESS_LADDER` and `ENDPOINT_CHROMA` rather than its own\n * literals, so a strict tone kit's endpoints land on the same ladder rungs a\n * generated ramp would have used. This is the single definition — callers must\n * not hand-write their own near-white/near-black.\n */\nexport function tintedEndpoints(hue: number, chroma?: number): { light: string; dark: string } {\n return {\n light: oklchToHex({\n l: LIGHTNESS_LADDER[50],\n c: Math.min(chroma ?? ENDPOINT_CHROMA.light, 0.02),\n h: hue,\n }),\n dark: oklchToHex({\n l: LIGHTNESS_LADDER[950],\n c: Math.min((chroma ?? ENDPOINT_CHROMA.dark) * 1.5, 0.03),\n h: hue,\n }),\n };\n}\n","/**\n * Color harmony strategies for generating complementary colors\n */\n\nexport type HarmonyStrategy =\n | 'complement'\n | 'analogous-plus'\n | 'analogous-minus'\n | 'triadic-plus'\n | 'triadic-minus'\n | 'split-plus'\n | 'split-minus'\n | 'tetradic-plus'\n | 'tetradic-minus';\n\nexport interface HarmonyOption {\n id: HarmonyStrategy;\n label: string;\n description: string;\n hueShift: number;\n}\n\nexport const HARMONY_STRATEGIES: HarmonyOption[] = [\n {\n id: 'complement',\n label: 'Complement',\n description: 'Opposite on color wheel (high contrast)',\n hueShift: 180,\n },\n {\n id: 'analogous-plus',\n label: 'Analogous+',\n description: 'Adjacent colors (harmonious)',\n hueShift: 30,\n },\n {\n id: 'analogous-minus',\n label: 'Analogous-',\n description: 'Adjacent colors (harmonious)',\n hueShift: -30,\n },\n {\n id: 'triadic-plus',\n label: 'Triadic+',\n description: 'Evenly spaced (balanced)',\n hueShift: 120,\n },\n {\n id: 'triadic-minus',\n label: 'Triadic-',\n description: 'Evenly spaced (balanced)',\n hueShift: -120,\n },\n {\n id: 'split-plus',\n label: 'Split Complement+',\n description: 'Near opposite (softer contrast)',\n hueShift: 150,\n },\n {\n id: 'split-minus',\n label: 'Split Complement-',\n description: 'Near opposite (softer contrast)',\n hueShift: -150,\n },\n {\n id: 'tetradic-plus',\n label: 'Tetradic+',\n description: 'Square spacing (rich palette)',\n hueShift: 90,\n },\n {\n id: 'tetradic-minus',\n label: 'Tetradic-',\n description: 'Square spacing (rich palette)',\n hueShift: -90,\n },\n];\n\n/**\n * Applies a harmony strategy to a hue value\n */\nexport function applyHarmonyStrategy(baseHue: number, strategy: HarmonyStrategy): number {\n const harmonyOption = HARMONY_STRATEGIES.find((h) => h.id === strategy);\n if (!harmonyOption) return baseHue;\n\n let newHue = baseHue + harmonyOption.hueShift;\n\n // Normalize to 0-360 range\n while (newHue < 0) newHue += 360;\n while (newHue >= 360) newHue -= 360;\n\n return newHue;\n}\n\n/**\n * Gets all harmony suggestions for a given base hue\n */\nexport function getHarmonySuggestions(\n baseHue: number,\n): Array<{ strategy: HarmonyStrategy; hue: number; label: string; description: string }> {\n return HARMONY_STRATEGIES.map((strategy) => ({\n strategy: strategy.id,\n hue: applyHarmonyStrategy(baseHue, strategy.id),\n label: strategy.label,\n description: strategy.description,\n }));\n}\n\n/**\n * Default functional color hues (if user doesn't specify)\n */\nexport const DEFAULT_FUNCTIONAL_HUES = {\n success: 142, // Green\n warning: 38, // Orange/Amber\n error: 0, // Red\n info: 200, // Blue\n} as const;\n\n/**\n * Suggests a functional color hue based on existing palette to maintain harmony\n */\nexport function suggestFunctionalColorHue(\n type: keyof typeof DEFAULT_FUNCTIONAL_HUES,\n existingHues: number[],\n): number {\n const defaultHue = DEFAULT_FUNCTIONAL_HUES[type];\n\n if (existingHues.length === 0) {\n return defaultHue;\n }\n\n // Find the closest existing hue and adjust slightly to maintain harmony\n const avgHue = existingHues.reduce((sum, h) => sum + h, 0) / existingHues.length;\n\n // Rotate the default hue to be more in line with the palette's color temperature\n const hueShift = avgHue - 200; // Assuming 200 is neutral blue\n let adjustedHue = defaultHue + hueShift * 0.3; // 30% influence\n\n // Normalize\n while (adjustedHue < 0) adjustedHue += 360;\n while (adjustedHue >= 360) adjustedHue -= 360;\n\n return adjustedHue;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":["OKLCH_Space","sRGB_Space","P3_Space"],"sources":["../../src/color/colors.ts","../../src/color/color-harmony.ts"],"sourcesContent":["import Color from 'colorjs.io';\nimport {\n OKLCH as OKLCH_Space,\n sRGB as sRGB_Space,\n P3 as P3_Space,\n ColorSpace,\n to,\n toGamutCSS,\n clone,\n serialize,\n type PlainColorObject,\n} from 'colorjs.io/fn';\n\nColorSpace.register(OKLCH_Space);\nColorSpace.register(sRGB_Space);\nColorSpace.register(P3_Space);\n\nexport interface RgbResult {\n r: number;\n g: number;\n b: number;\n inGamut: boolean;\n /** Max channel overshoot beyond [0,1] before clamping. 0 when in gamut. */\n gamutDistance: number;\n}\n\n/**\n * Convert OKLCh to sRGB, returning 0-255 clamped values with gamut flag.\n * Fast path for per-pixel canvas rendering (no gamut mapping, just clamp).\n */\nexport function oklchToSrgb(l: number, c: number, h: number): RgbResult {\n const col: PlainColorObject = { space: OKLCH_Space, coords: [l, c, h], alpha: 1 };\n const rgb = to(col, sRGB_Space);\n const [r, g, b] = rgb.coords;\n const inGamut = r >= 0 && r <= 1 && g >= 0 && g <= 1 && b >= 0 && b <= 1;\n const gamutDistance = inGamut ? 0 : Math.max(-r, r - 1, -g, g - 1, -b, b - 1, 0);\n return {\n r: Math.max(0, Math.min(255, Math.round(r * 255))),\n g: Math.max(0, Math.min(255, Math.round(g * 255))),\n b: Math.max(0, Math.min(255, Math.round(b * 255))),\n inGamut,\n gamutDistance,\n };\n}\n\n/**\n * Convert OKLCh to Display P3, returning 0-255 clamped values with gamut flag.\n * Fast path for per-pixel canvas rendering on P3 displays.\n */\nexport function oklchToP3(l: number, c: number, h: number): RgbResult {\n const col: PlainColorObject = { space: OKLCH_Space, coords: [l, c, h], alpha: 1 };\n const rgb = to(col, P3_Space);\n const [r, g, b] = rgb.coords;\n const inGamut = r >= 0 && r <= 1 && g >= 0 && g <= 1 && b >= 0 && b <= 1;\n const gamutDistance = inGamut ? 0 : Math.max(-r, r - 1, -g, g - 1, -b, b - 1, 0);\n return {\n r: Math.max(0, Math.min(255, Math.round(r * 255))),\n g: Math.max(0, Math.min(255, Math.round(g * 255))),\n b: Math.max(0, Math.min(255, Math.round(b * 255))),\n inGamut,\n gamutDistance,\n };\n}\n\n/**\n * Gamut-map an OKLCh color using the CSS gamut mapping algorithm, return hex.\n * Slower than oklchToSrgb/oklchToP3 (uses iterative reduction), use for single colors only.\n */\nexport function gamutMapOklchToHex(\n l: number,\n c: number,\n h: number,\n space: 'srgb' | 'p3' = 'srgb',\n): string {\n const col: PlainColorObject = { space: OKLCH_Space, coords: [l, c, h], alpha: 1 };\n const targetSpace = space === 'p3' ? P3_Space : sRGB_Space;\n const mapped = toGamutCSS(clone(col), { space: targetSpace });\n const rgb = to(mapped, sRGB_Space);\n return serialize(rgb, { format: 'hex' });\n}\n\n// Standard 11-step scale - will be transposed based on input anchors\nexport const STEP_NAMES = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const;\nexport type StepName = (typeof STEP_NAMES)[number];\n\nexport const COLOR_GROUPS = [\n 'neutral',\n 'brand',\n 'primary',\n 'accent',\n 'complement',\n 'success',\n 'warning',\n 'error',\n 'info',\n] as const;\nexport type ColorGroup = (typeof COLOR_GROUPS)[number];\n\n/**\n * Convert hex color to OKLCH components\n */\nexport function hexToOklch(hex: string): { l: number; c: number; h: number } {\n const color = new Color(hex);\n const oklch = color.to('oklch');\n return {\n l: oklch.coords[0],\n c: oklch.coords[1],\n h: oklch.coords[2] || 0,\n };\n}\n\nexport function oklchToHex(oklch: { l: number; c: number; h: number }): string {\n const color = new Color('oklch', [oklch.l, oklch.c, oklch.h]);\n const srgb = color.to('srgb');\n if (!srgb.inGamut()) {\n srgb.toGamut({ method: 'css' });\n }\n return srgb.toString({ format: 'hex' });\n}\n\n/**\n * Convert OKLCH CSS string to hex color\n * @example oklchStringToHex(\"oklch(50% 0.15 270)\") => \"#8b5cf6\"\n */\nexport function oklchStringToHex(oklchString: string): string {\n try {\n const color = new Color(oklchString);\n const srgb = color.to('srgb');\n if (!srgb.inGamut()) {\n srgb.toGamut({ method: 'css' });\n }\n return srgb.toString({ format: 'hex' });\n } catch {\n return '#808080';\n }\n}\n\n/**\n * Format a color name by separating compound words\n * Examples: \"mediumseagreen\" -> \"Medium Sea Green\", \"darkblue\" -> \"Dark Blue\"\n */\nexport function formatColorName(name: string): string {\n if (!name) return 'Unknown';\n\n const specialCases: Record<string, string> = {\n aliceblue: 'Alice Blue',\n antiquewhite: 'Antique White',\n blanchedalmond: 'Blanched Almond',\n blueviolet: 'Blue Violet',\n burlywood: 'Burly Wood',\n cadetblue: 'Cadet Blue',\n cornflowerblue: 'Cornflower Blue',\n darkblue: 'Dark Blue',\n darkcyan: 'Dark Cyan',\n darkgoldenrod: 'Dark Goldenrod',\n darkgray: 'Dark Gray',\n darkgreen: 'Dark Green',\n darkgrey: 'Dark Grey',\n darkkhaki: 'Dark Khaki',\n darkmagenta: 'Dark Magenta',\n darkolivegreen: 'Dark Olive Green',\n darkorange: 'Dark Orange',\n darkorchid: 'Dark Orchid',\n darkred: 'Dark Red',\n darksalmon: 'Dark Salmon',\n darkseagreen: 'Dark Sea Green',\n darkslateblue: 'Dark Slate Blue',\n darkslategray: 'Dark Slate Gray',\n darkslategrey: 'Dark Slate Grey',\n darkturquoise: 'Dark Turquoise',\n darkviolet: 'Dark Violet',\n deeppink: 'Deep Pink',\n deepskyblue: 'Deep Sky Blue',\n dimgray: 'Dim Gray',\n dimgrey: 'Dim Grey',\n dodgerblue: 'Dodger Blue',\n floralwhite: 'Floral White',\n forestgreen: 'Forest Green',\n ghostwhite: 'Ghost White',\n greenyellow: 'Green Yellow',\n hotpink: 'Hot Pink',\n indianred: 'Indian Red',\n lavenderblush: 'Lavender Blush',\n lawngreen: 'Lawn Green',\n lemonchiffon: 'Lemon Chiffon',\n lightblue: 'Light Blue',\n lightcoral: 'Light Coral',\n lightcyan: 'Light Cyan',\n lightgoldenrodyellow: 'Light Goldenrod Yellow',\n lightgray: 'Light Gray',\n lightgreen: 'Light Green',\n lightgrey: 'Light Grey',\n lightpink: 'Light Pink',\n lightsalmon: 'Light Salmon',\n lightseagreen: 'Light Sea Green',\n lightskyblue: 'Light Sky Blue',\n lightslategray: 'Light Slate Gray',\n lightslategrey: 'Light Slate Grey',\n lightsteelblue: 'Light Steel Blue',\n lightyellow: 'Light Yellow',\n limegreen: 'Lime Green',\n mediumaquamarine: 'Medium Aquamarine',\n mediumblue: 'Medium Blue',\n mediumorchid: 'Medium Orchid',\n mediumpurple: 'Medium Purple',\n mediumseagreen: 'Medium Sea Green',\n mediumslateblue: 'Medium Slate Blue',\n mediumspringgreen: 'Medium Spring Green',\n mediumturquoise: 'Medium Turquoise',\n mediumvioletred: 'Medium Violet Red',\n midnightblue: 'Midnight Blue',\n mintcream: 'Mint Cream',\n mistyrose: 'Misty Rose',\n navajowhite: 'Navajo White',\n oldlace: 'Old Lace',\n olivedrab: 'Olive Drab',\n orangered: 'Orange Red',\n palegoldenrod: 'Pale Goldenrod',\n palegreen: 'Pale Green',\n paleturquoise: 'Pale Turquoise',\n palevioletred: 'Pale Violet Red',\n papayawhip: 'Papaya Whip',\n peachpuff: 'Peach Puff',\n powderblue: 'Powder Blue',\n rosybrown: 'Rosy Brown',\n royalblue: 'Royal Blue',\n saddlebrown: 'Saddle Brown',\n sandybrown: 'Sandy Brown',\n seagreen: 'Sea Green',\n skyblue: 'Sky Blue',\n slateblue: 'Slate Blue',\n slategray: 'Slate Gray',\n slategrey: 'Slate Grey',\n springgreen: 'Spring Green',\n steelblue: 'Steel Blue',\n whitesmoke: 'White Smoke',\n yellowgreen: 'Yellow Green',\n };\n\n const lowerName = name.toLowerCase();\n if (specialCases[lowerName]) {\n return specialCases[lowerName];\n }\n\n // Default: just capitalize first letter\n return name.charAt(0).toUpperCase() + name.slice(1);\n}\n\n/**\n * Get the closest CSS color name for a hex color\n */\nexport function getColorName(hex: string): string {\n try {\n const color = new Color(hex);\n const oklch = color.to('oklch');\n const hue = oklch.coords[2] || 0;\n\n if (hue < 30 || hue >= 330) return 'Red';\n if (hue < 60) return 'Orange';\n if (hue < 90) return 'Yellow';\n if (hue < 150) return 'Green';\n if (hue < 210) return 'Cyan';\n if (hue < 270) return 'Blue';\n if (hue < 330) return 'Purple';\n return 'Unknown';\n } catch {\n return 'Unknown';\n }\n}\n\n/**\n * Get color name with hue value\n */\nexport function getColorNameWithHue(hex: string): string {\n try {\n const color = new Color(hex);\n const oklch = color.to('oklch');\n const hue = oklch.coords[2] || 0;\n const roundedHue = Math.round(hue / 10) * 10;\n const name = getColorName(hex);\n return `${name} ${roundedHue}`;\n } catch {\n return 'Unknown';\n }\n}\n\n// Color harmony definitions\nexport const HARMONY_TYPES = [\n 'single',\n 'analogous',\n 'triad',\n 'complementary',\n 'split-complementary',\n 'square',\n 'compound',\n] as const;\nexport type HarmonyType = (typeof HARMONY_TYPES)[number];\n\nexport interface HarmonyDefinition {\n label: string;\n offsets: number[];\n primaryIndex: number;\n}\n\nexport const HARMONY_DEFINITIONS: Record<HarmonyType, HarmonyDefinition> = {\n single: {\n label: 'Single',\n offsets: [0],\n primaryIndex: 0,\n },\n analogous: {\n label: 'Analogous',\n offsets: [-30, 0, 30],\n primaryIndex: 1,\n },\n triad: {\n label: 'Triad',\n offsets: [0, 120, 240],\n primaryIndex: 0,\n },\n complementary: {\n label: 'Complementary',\n offsets: [0, 180],\n primaryIndex: 0,\n },\n 'split-complementary': {\n label: 'Split Complementary',\n offsets: [0, 150, 210],\n primaryIndex: 0,\n },\n square: {\n label: 'Square',\n offsets: [0, 90, 180, 270],\n primaryIndex: 0,\n },\n compound: {\n label: 'Compound',\n offsets: [-30, 0, 180, 210],\n primaryIndex: 1,\n },\n};\n\n/**\n * The shared lightness ladder every ramp is built on (lightest → darkest).\n *\n * Fixed, NOT seed-derived. Two hues at the same step have to read equally light,\n * otherwise one contrast table cannot serve every hue and a `muted` variant means\n * something different depending on which role you ask. Only chroma and hue vary\n * between ramps.\n *\n * The previous engine transposed this curve onto each seed, which let relative\n * luminance at step 300 range from 0.253 (rust) to 0.384 (amber) across the\n * shipped palette — the drift this replaces.\n *\n * Anchored steps are the one deliberate exception: see `generateOklchPalette`.\n */\nexport const LIGHTNESS_LADDER: Record<StepName, number> = {\n 50: 0.98,\n 100: 0.95,\n 200: 0.9,\n 300: 0.83,\n 400: 0.74,\n 500: 0.65,\n 600: 0.54,\n 700: 0.47,\n 800: 0.38,\n 900: 0.29,\n 950: 0.21,\n};\n\n/**\n * Chroma a ramp decays *towards* beyond its outermost anchors, so the near-white\n * and near-black ends keep a whisper of the hue instead of going flat grey. The\n * dark end carries slightly more because chroma reads weaker against black.\n *\n * It is a CEILING, not a target — `Math.min(ENDPOINT_CHROMA.x, anchor.c)`. That\n * distinction is the whole reason a neutral is authorable. The interpolation\n * factor reaches exactly 1 at steps 50 and 950 for every anchor position, so as a\n * target it made the endpoints these constants *unconditionally*: seeds at chroma\n * 0.001, 0.002, 0.05 and 0.2 all produced a byte-identical step 50. A brand\n * wanting plain white with grey panels had no seed that worked, and chroma 0 was\n * worst of all — colorjs returns NaN hue for a true achromatic colour, which\n * `hexToOklch` collapses to 0, so the \"neutral\" came out pink.\n *\n * As a ceiling, a low-chroma seed simply stays low and a chroma-0 seed stays 0 at\n * every unanchored step, which makes the NaN-hue collapse invisible rather than\n * something `hexToOklch` has to solve (its `|| 0` is load-bearing for\n * `getColorName`).\n */\nexport const ENDPOINT_CHROMA = { light: 0.008, dark: 0.015 } as const;\n\ninterface OklchColor {\n l: number;\n c: number;\n h: number;\n}\n\n/**\n * Parses an OKLCH CSS string into numerical components.\n * @param oklchString - e.g., 'oklch(50% 0.15 270)'\n * @returns Lightness (0-1), Chroma (0-~0.37), Hue (0-360)\n */\nexport function parseOklch(oklchString: string): OklchColor {\n const parts = oklchString.match(/oklch\\(([^ ]+) ([^ ]+) ([^ ]+)\\)/);\n if (!parts) throw new Error('Invalid OKLCH string format.');\n\n const [, lRaw = '', cRaw = '', hRaw = ''] = parts;\n const l = parseFloat(lRaw) / (lRaw.includes('%') ? 100 : 1);\n const c = parseFloat(cRaw);\n const h = parseFloat(hRaw);\n\n return { l, c, h };\n}\n\n/**\n * Linearly interpolates a value between a start and end point.\n */\nfunction lerp(start: number, end: number, factor: number): number {\n return start + factor * (end - start);\n}\n\n/**\n * Interpolate hue the short way round the wheel. A ramp anchored at 350° and 10°\n * spans 20°, not 340° — the naive lerp sent it the long way through cyan.\n */\nfunction lerpHue(start: number, end: number, factor: number): number {\n const delta = ((((end - start) % 360) + 540) % 360) - 180;\n return (start + delta * factor + 360) % 360;\n}\n\n/**\n * The ladder step whose lightness is closest to `lightness` — i.e. where a\n * supplied colour naturally belongs.\n */\nexport function assignStepByLightness(lightness: number): StepName {\n let closestStep: StepName = 500;\n let minDiff = Math.abs(lightness - LIGHTNESS_LADDER[500]);\n\n for (const step of STEP_NAMES) {\n const diff = Math.abs(lightness - LIGHTNESS_LADDER[step]);\n if (diff < minDiff) {\n minDiff = diff;\n closestStep = step;\n }\n }\n\n return closestStep;\n}\n\n/**\n * How far an anchored colour sits from its step's ladder lightness.\n *\n * The generator surfaces this as a warning: a large deviation is legal (the\n * caller asked for that exact colour) but it means the hue will read heavier or\n * lighter than its siblings at that step, which is worth knowing rather than\n * discovering later.\n */\nexport function ladderDeviation(step: StepName, lightness: number): number {\n return lightness - LIGHTNESS_LADDER[step];\n}\n\n/** Absolute chroma ceiling — beyond this nothing survives gamut mapping anyway. */\nconst MAX_CHROMA = 0.37;\n\n/**\n * Build a complete 11-step OKLCH ramp from one or more anchor colours.\n *\n * **Ladder-first with exact anchors.** Every anchor is reproduced *verbatim* at\n * its step; every other step takes its lightness from `LIGHTNESS_LADDER`, with\n * chroma and hue interpolated between the flanking anchors and decayed toward\n * `ENDPOINT_CHROMA` beyond the outermost ones.\n *\n * The consequence is the point: a client's brand hex survives byte-for-byte,\n * while the other ten steps stay directly comparable to every other hue. The\n * deviation from the ladder is bounded and *local to anchored steps* — unlike\n * the previous engine, which warped the whole ramp to fit its anchors so every\n * step drifted.\n *\n * Chroma may come out beyond the sRGB gamut here; that is deliberate.\n * `oklchStringToHex` gamut-maps with the CSS algorithm, which reduces chroma\n * while holding lightness, so the ladder survives the conversion.\n */\nexport function generateOklchPalette(\n input: string | string[] | Record<string, string>,\n): Record<StepName, string> {\n let anchors: Record<number, string> = {};\n\n if (typeof input === 'string') {\n anchors[assignStepByLightness(parseOklch(input).l)] = input;\n } else if (Array.isArray(input)) {\n for (const colorStr of input) {\n anchors[assignStepByLightness(parseOklch(colorStr).l)] = colorStr;\n }\n } else if (typeof input === 'object' && input !== null) {\n anchors = { ...input };\n } else {\n throw new Error(\n 'Input must be a color string, array of colors, or object with step assignments',\n );\n }\n\n const parsedAnchors: Record<number, OklchColor> = {};\n for (const [key, value] of Object.entries(anchors)) {\n parsedAnchors[Number(key)] = parseOklch(value);\n }\n\n const anchorSteps = Object.keys(parsedAnchors)\n .map(Number)\n .sort((a, b) => a - b) as StepName[];\n\n // Interpolate across step *index*, not the numeric label: the labels aren't\n // evenly spaced (50→100 is a smaller jump than 100→200), so lerping on them\n // would bunch the ramp up at the light end.\n const idx = (step: number) => STEP_NAMES.indexOf(step as StepName);\n const lastIdx = STEP_NAMES.length - 1;\n\n const palette: Record<number, string> = {};\n\n for (const step of STEP_NAMES) {\n // An anchor is the caller's exact colour. Emit it untouched.\n if (parsedAnchors[step]) {\n palette[step] = anchors[step] as string;\n continue;\n }\n\n // `lighter` is the nearest anchor with a smaller step (50 is lightest);\n // `darker` the nearest with a larger one.\n const lighter = [...anchorSteps].reverse().find((s) => s < step);\n const darker = anchorSteps.find((s) => s > step);\n\n let chroma: number;\n let hue: number;\n\n if (lighter !== undefined && darker !== undefined) {\n const a = parsedAnchors[lighter] as OklchColor;\n const b = parsedAnchors[darker] as OklchColor;\n const factor = (idx(step) - idx(lighter)) / (idx(darker) - idx(lighter));\n chroma = lerp(a.c, b.c, factor);\n hue = lerpHue(a.h, b.h, factor);\n } else if (lighter !== undefined) {\n // Past the darkest anchor — fade toward the tinted near-black end.\n const a = parsedAnchors[lighter] as OklchColor;\n const factor = (idx(step) - idx(lighter)) / (lastIdx - idx(lighter));\n chroma = lerp(a.c, Math.min(ENDPOINT_CHROMA.dark, a.c), factor);\n hue = a.h;\n } else if (darker !== undefined) {\n // Past the lightest anchor — fade toward the tinted near-white end.\n const b = parsedAnchors[darker] as OklchColor;\n const factor = (idx(darker) - idx(step)) / idx(darker);\n chroma = lerp(b.c, Math.min(ENDPOINT_CHROMA.light, b.c), factor);\n hue = b.h;\n } else {\n chroma = 0.1;\n hue = 0;\n }\n\n const lightness = LIGHTNESS_LADDER[step];\n const finalL = (lightness * 100).toFixed(2) + '%';\n const finalC = Math.min(chroma, MAX_CHROMA).toFixed(4);\n const finalH = hue.toFixed(2);\n\n palette[step] = `oklch(${finalL} ${finalC} ${finalH})`;\n }\n\n return palette;\n}\n\n// ── Contrast + endpoints (for the functional-token layer) ────────────────────\n\n/** APCA lightness-contrast (Lc) magnitude between a foreground and background. */\nexport function contrastLc(fg: string, bg: string): number {\n return Math.abs(new Color(bg).contrast(fg, 'APCA'));\n}\n\n/**\n * Pick a tinted near-white or near-black foreground for best contrast on `bg`\n * (a whisper of the background's hue mixed into off-white/off-black).\n *\n * `prefer` forces the polarity instead of choosing the better one — for a role\n * whose author wants dark text on a bright fill (or the reverse). Whether that\n * pairing is legible is the caller's contrast check to decide, not this\n * function's.\n */\nexport function pickOn(bg: string, hue?: number, prefer?: 'light' | 'dark'): string {\n const h = hue ?? hexToOklch(bg).h;\n const white = oklchToHex({ l: 0.98, c: 0.01, h });\n const black = oklchToHex({ l: 0.16, c: 0.02, h });\n if (prefer === 'light') return white;\n if (prefer === 'dark') return black;\n return contrastLc(white, bg) >= contrastLc(black, bg) ? white : black;\n}\n\n/**\n * The tinted off-white / off-black endpoints for a hue — white/black with a\n * whisper of the hue mixed in. These are the text/background tints every ramp\n * needs (and the one exception strict brand kits allow).\n *\n * Derived from `LIGHTNESS_LADDER` and `ENDPOINT_CHROMA` rather than its own\n * literals, so a strict tone kit's endpoints land on the same ladder rungs a\n * generated ramp would have used. This is the single definition — callers must\n * not hand-write their own near-white/near-black.\n */\nexport function tintedEndpoints(hue: number, chroma?: number): { light: string; dark: string } {\n return {\n light: oklchToHex({\n l: LIGHTNESS_LADDER[50],\n c: Math.min(chroma ?? ENDPOINT_CHROMA.light, 0.02),\n h: hue,\n }),\n dark: oklchToHex({\n l: LIGHTNESS_LADDER[950],\n c: Math.min((chroma ?? ENDPOINT_CHROMA.dark) * 1.5, 0.03),\n h: hue,\n }),\n };\n}\n","/**\n * Color harmony strategies for generating complementary colors\n */\n\nexport type HarmonyStrategy =\n | 'complement'\n | 'analogous-plus'\n | 'analogous-minus'\n | 'triadic-plus'\n | 'triadic-minus'\n | 'split-plus'\n | 'split-minus'\n | 'tetradic-plus'\n | 'tetradic-minus';\n\nexport interface HarmonyOption {\n id: HarmonyStrategy;\n label: string;\n description: string;\n hueShift: number;\n}\n\nexport const HARMONY_STRATEGIES: HarmonyOption[] = [\n {\n id: 'complement',\n label: 'Complement',\n description: 'Opposite on color wheel (high contrast)',\n hueShift: 180,\n },\n {\n id: 'analogous-plus',\n label: 'Analogous+',\n description: 'Adjacent colors (harmonious)',\n hueShift: 30,\n },\n {\n id: 'analogous-minus',\n label: 'Analogous-',\n description: 'Adjacent colors (harmonious)',\n hueShift: -30,\n },\n {\n id: 'triadic-plus',\n label: 'Triadic+',\n description: 'Evenly spaced (balanced)',\n hueShift: 120,\n },\n {\n id: 'triadic-minus',\n label: 'Triadic-',\n description: 'Evenly spaced (balanced)',\n hueShift: -120,\n },\n {\n id: 'split-plus',\n label: 'Split Complement+',\n description: 'Near opposite (softer contrast)',\n hueShift: 150,\n },\n {\n id: 'split-minus',\n label: 'Split Complement-',\n description: 'Near opposite (softer contrast)',\n hueShift: -150,\n },\n {\n id: 'tetradic-plus',\n label: 'Tetradic+',\n description: 'Square spacing (rich palette)',\n hueShift: 90,\n },\n {\n id: 'tetradic-minus',\n label: 'Tetradic-',\n description: 'Square spacing (rich palette)',\n hueShift: -90,\n },\n];\n\n/**\n * Applies a harmony strategy to a hue value\n */\nexport function applyHarmonyStrategy(baseHue: number, strategy: HarmonyStrategy): number {\n const harmonyOption = HARMONY_STRATEGIES.find((h) => h.id === strategy);\n if (!harmonyOption) return baseHue;\n\n let newHue = baseHue + harmonyOption.hueShift;\n\n // Normalize to 0-360 range\n while (newHue < 0) newHue += 360;\n while (newHue >= 360) newHue -= 360;\n\n return newHue;\n}\n\n/**\n * Gets all harmony suggestions for a given base hue\n */\nexport function getHarmonySuggestions(\n baseHue: number,\n): Array<{ strategy: HarmonyStrategy; hue: number; label: string; description: string }> {\n return HARMONY_STRATEGIES.map((strategy) => ({\n strategy: strategy.id,\n hue: applyHarmonyStrategy(baseHue, strategy.id),\n label: strategy.label,\n description: strategy.description,\n }));\n}\n\n/**\n * Default functional color hues (if user doesn't specify)\n */\nexport const DEFAULT_FUNCTIONAL_HUES = {\n success: 142, // Green\n warning: 38, // Orange/Amber\n error: 0, // Red\n info: 200, // Blue\n} as const;\n\n/**\n * Suggests a functional color hue based on existing palette to maintain harmony\n */\nexport function suggestFunctionalColorHue(\n type: keyof typeof DEFAULT_FUNCTIONAL_HUES,\n existingHues: number[],\n): number {\n const defaultHue = DEFAULT_FUNCTIONAL_HUES[type];\n\n if (existingHues.length === 0) {\n return defaultHue;\n }\n\n // Find the closest existing hue and adjust slightly to maintain harmony\n const avgHue = existingHues.reduce((sum, h) => sum + h, 0) / existingHues.length;\n\n // Rotate the default hue to be more in line with the palette's color temperature\n const hueShift = avgHue - 200; // Assuming 200 is neutral blue\n let adjustedHue = defaultHue + hueShift * 0.3; // 30% influence\n\n // Normalize\n while (adjustedHue < 0) adjustedHue += 360;\n while (adjustedHue >= 360) adjustedHue -= 360;\n\n return adjustedHue;\n}\n"],"mappings":"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"}
@@ -0,0 +1,82 @@
1
+ import { ReviewLocationConfig, ReviewsConfig } from "@getvitops/config";
2
+ /** One review, as GBP returns it and the cache stores it. */
3
+ interface Review {
4
+ id: string;
5
+ /** The `organization.locations[]` key this review belongs to. */
6
+ location: string;
7
+ /** 1–5. */
8
+ rating: number;
9
+ author: string;
10
+ authorPhoto?: string | undefined;
11
+ text?: string | undefined;
12
+ publishedAt: string;
13
+ updatedAt: string;
14
+ reply?: {
15
+ text: string;
16
+ at: string;
17
+ } | undefined;
18
+ /** Link back to the review on Google — never omit when linking to it. */
19
+ url?: string | undefined;
20
+ }
21
+ /** What a fetch of one location produced. */
22
+ interface LocationReviews {
23
+ locationId: string;
24
+ accountId: string;
25
+ fetchedAt: string;
26
+ averageRating?: number | undefined;
27
+ totalReviewCount?: number | undefined;
28
+ reviews: Review[];
29
+ }
30
+ /** The on-disk cache, as written by `writeCache`. */
31
+ interface ReviewCache {
32
+ version: 1;
33
+ locations: Record<string, LocationReviews>;
34
+ }
35
+ /** One location's fetch plan, or the reason it was skipped. */
36
+ interface LocationPlan {
37
+ key: string;
38
+ accountId?: string;
39
+ locationId?: string;
40
+ /** Present iff this location will not be fetched. */
41
+ skip?: string;
42
+ }
43
+ interface ReviewsPlan {
44
+ /** Set when the whole run is refused — nothing is fetched. */
45
+ blocked?: string;
46
+ locations: LocationPlan[];
47
+ notes: string[];
48
+ }
49
+ interface PlanInput {
50
+ config: ReviewsConfig | undefined;
51
+ /** Location keys with a Google listing configured (`organization.locations[].listings.google`). */
52
+ knownLocations: Set<string>;
53
+ /** `--location`: fetch exactly this one, skipping the rest. */
54
+ onlyLocation?: string | undefined;
55
+ /** Whether a Google credential was resolved. */
56
+ hasCredential: boolean;
57
+ }
58
+ /** Decide which locations to fetch, and why any are skipped. */
59
+ declare function plan(input: PlanInput): ReviewsPlan;
60
+ /** Render a plan as the human-readable block `--dry` prints. */
61
+ declare function formatPlan(p: ReviewsPlan): string;
62
+ /**
63
+ * Filter and order one location's reviews for display.
64
+ *
65
+ * Featured ids always win and keep the author's order — an explicit choice
66
+ * overrides any automatic ranking. The remainder is filtered by `minRating`
67
+ * (default 4 — a star-rating widget showing a 1-star review it did not have to
68
+ * is a design choice a config field should not have to spell out every time),
69
+ * sorted, then capped by `limit`.
70
+ */
71
+ declare function selectReviews(reviews: Review[], config: Partial<ReviewLocationConfig>): Review[];
72
+ /**
73
+ * Merge a fresh fetch into the existing cache.
74
+ *
75
+ * Only the locations actually fetched this run are replaced; every other
76
+ * location's prior reviews pass through untouched. This is what makes a partial
77
+ * failure (one location's fetch errors) non-destructive — `cmdReviews` merges
78
+ * only the locations that succeeded and leaves the rest of the cache alone.
79
+ */
80
+ declare function mergeCache(existing: ReviewCache | undefined, fetched: Record<string, LocationReviews>): ReviewCache;
81
+ export { mergeCache as a, LocationReviews as c, ReviewLocationConfig as d, ReviewsConfig as f, formatPlan as i, Review as l, PlanInput as n, plan as o, ReviewsPlan as r, selectReviews as s, LocationPlan as t, ReviewCache as u };
82
+ //# sourceMappingURL=plan-CddqsmJJ.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plan-CddqsmJJ.d.mts","names":[],"sources":["../src/reviews/types.ts","../src/reviews/plan.ts"],"mappings":";;UAkBiB;EACf;;EAEA;;EAEA;EACA;EACA;EACA;EACA;EACA;EACA;IAAU;IAAc;;;EAExB;;;UAIe;EACf;EACA;EACA;EACA;EACA;EACA,SAAS;;;UAIM;EACf;EACA,WAAW,eAAe;;;UC5BX;EACf;EACA;EACA;;EAEA;;UAGe;;EAEf;EACA,WAAW;EACX;;UAGe;EACf,QAAQ;;EAER,gBAAgB;;EAEhB;;EAEA;;;iBAIc,KAAK,OAAO,YAAY;;iBAuDxB,WAAW,GAAG;;;;;;;;;;iBAmBd,cACd,SAAS,UAGT,QAAQ,QAAQ,wBACf;;;;;;;;;iBA2Ba,WACd,UAAU,yBACV,SAAS,eAAe,mBACvB"}
@@ -0,0 +1,93 @@
1
+ // src/reviews/plan.ts
2
+ /** Decide which locations to fetch, and why any are skipped. */
3
+ function plan(input) {
4
+ const { config, knownLocations, onlyLocation, hasCredential } = input;
5
+ const notes = [];
6
+ if (!config || Object.keys(config.locations).length === 0) return {
7
+ blocked: "no site.reviews.locations configured — nothing to fetch",
8
+ locations: [],
9
+ notes
10
+ };
11
+ if (!hasCredential) return {
12
+ blocked: "no Google credential — set VITOPS_GOOGLE_CLIENT_ID / _CLIENT_SECRET / _REFRESH_TOKEN, or run `gcloud auth application-default login`",
13
+ locations: [],
14
+ notes
15
+ };
16
+ const entries = onlyLocation ? Object.entries(config.locations).filter(([key]) => key === onlyLocation) : Object.entries(config.locations);
17
+ if (onlyLocation && entries.length === 0) return {
18
+ blocked: `--location "${onlyLocation}" is not in site.reviews.locations`,
19
+ locations: [],
20
+ notes
21
+ };
22
+ const locations = entries.map(([key, loc]) => {
23
+ if (!knownLocations.has(key)) return {
24
+ key,
25
+ skip: `organization.locations has no entry keyed "${key}" with a listings.google id`
26
+ };
27
+ const accountId = loc.accountId ?? config.accountId;
28
+ if (!accountId) return {
29
+ key,
30
+ skip: `no accountId — set site.reviews.accountId or locations.${key}.accountId`
31
+ };
32
+ return {
33
+ key,
34
+ accountId,
35
+ locationId: loc.locationId
36
+ };
37
+ });
38
+ const blocked = locations.filter((l) => l.skip);
39
+ if (blocked.length) for (const l of blocked) notes.push(`${l.key}: skipped — ${l.skip}`);
40
+ return {
41
+ locations,
42
+ notes
43
+ };
44
+ }
45
+ /** Render a plan as the human-readable block `--dry` prints. */
46
+ function formatPlan(p) {
47
+ if (p.blocked) return `✗ blocked — ${p.blocked}`;
48
+ const lines = [];
49
+ const active = p.locations.filter((l) => !l.skip);
50
+ lines.push(`locations ${active.length} of ${p.locations.length} will be fetched`);
51
+ for (const l of active) lines.push(` ${l.key} ${l.accountId}/${l.locationId}`);
52
+ for (const n of p.notes) lines.push(` ! ${n}`);
53
+ return lines.join("\n");
54
+ }
55
+ /**
56
+ * Filter and order one location's reviews for display.
57
+ *
58
+ * Featured ids always win and keep the author's order — an explicit choice
59
+ * overrides any automatic ranking. The remainder is filtered by `minRating`
60
+ * (default 4 — a star-rating widget showing a 1-star review it did not have to
61
+ * is a design choice a config field should not have to spell out every time),
62
+ * sorted, then capped by `limit`.
63
+ */
64
+ function selectReviews(reviews, config) {
65
+ const featuredIds = config.featured ?? [];
66
+ const byId = new Map(reviews.map((r) => [r.id, r]));
67
+ const featured = featuredIds.map((id) => byId.get(id)).filter((r) => !!r);
68
+ const featuredSet = new Set(featured.map((r) => r.id));
69
+ const minRating = config.minRating ?? 4;
70
+ const rest = reviews.filter((r) => !featuredSet.has(r.id) && r.rating >= minRating).sort((a, b) => config.sort === "rating" ? b.rating - a.rating : new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime());
71
+ const combined = [...featured, ...rest];
72
+ return config.limit !== void 0 ? combined.slice(0, config.limit) : combined;
73
+ }
74
+ /**
75
+ * Merge a fresh fetch into the existing cache.
76
+ *
77
+ * Only the locations actually fetched this run are replaced; every other
78
+ * location's prior reviews pass through untouched. This is what makes a partial
79
+ * failure (one location's fetch errors) non-destructive — `cmdReviews` merges
80
+ * only the locations that succeeded and leaves the rest of the cache alone.
81
+ */
82
+ function mergeCache(existing, fetched) {
83
+ return {
84
+ version: 1,
85
+ locations: {
86
+ ...existing?.locations,
87
+ ...fetched
88
+ }
89
+ };
90
+ }
91
+
92
+ export { selectReviews as i, mergeCache as n, plan as r, formatPlan as t };
93
+ //# sourceMappingURL=plan-DpHv6NO8.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plan-DpHv6NO8.mjs","names":[],"sources":["../src/reviews/plan.ts"],"sourcesContent":["/**\n * Every decision `vitops reviews` makes, as pure functions.\n *\n * Same split as `../indexing/plan.ts`: no `fetch`, no `fs`, no clock. Which\n * locations to fetch (and why one is blocked), and how a location's reviews are\n * filtered down to what a site actually displays, are decided here — `gbp.ts`\n * and `cache.ts` do what they're told and nothing else. That is what lets Astro's\n * `<ReviewCard>` and a future Bricks renderer both call `selectReviews` instead\n * of each re-implementing \"top 5 by rating, minus the ones already featured\".\n */\nimport type {\n LocationReviews,\n Review,\n ReviewCache,\n ReviewLocationConfig,\n ReviewsConfig,\n} from './types.ts';\n\n/** One location's fetch plan, or the reason it was skipped. */\nexport interface LocationPlan {\n key: string;\n accountId?: string;\n locationId?: string;\n /** Present iff this location will not be fetched. */\n skip?: string;\n}\n\nexport interface ReviewsPlan {\n /** Set when the whole run is refused — nothing is fetched. */\n blocked?: string;\n locations: LocationPlan[];\n notes: string[];\n}\n\nexport interface PlanInput {\n config: ReviewsConfig | undefined;\n /** Location keys with a Google listing configured (`organization.locations[].listings.google`). */\n knownLocations: Set<string>;\n /** `--location`: fetch exactly this one, skipping the rest. */\n onlyLocation?: string | undefined;\n /** Whether a Google credential was resolved. */\n hasCredential: boolean;\n}\n\n/** Decide which locations to fetch, and why any are skipped. */\nexport function plan(input: PlanInput): ReviewsPlan {\n const { config, knownLocations, onlyLocation, hasCredential } = input;\n const notes: string[] = [];\n\n if (!config || Object.keys(config.locations).length === 0) {\n return {\n blocked: 'no site.reviews.locations configured — nothing to fetch',\n locations: [],\n notes,\n };\n }\n\n if (!hasCredential) {\n return {\n blocked:\n 'no Google credential — set VITOPS_GOOGLE_CLIENT_ID / _CLIENT_SECRET / _REFRESH_TOKEN, or run `gcloud auth application-default login`',\n locations: [],\n notes,\n };\n }\n\n const entries = onlyLocation\n ? Object.entries(config.locations).filter(([key]) => key === onlyLocation)\n : Object.entries(config.locations);\n\n if (onlyLocation && entries.length === 0) {\n return {\n blocked: `--location \"${onlyLocation}\" is not in site.reviews.locations`,\n locations: [],\n notes,\n };\n }\n\n const locations: LocationPlan[] = entries.map(([key, loc]) => {\n if (!knownLocations.has(key))\n return {\n key,\n skip: `organization.locations has no entry keyed \"${key}\" with a listings.google id`,\n };\n const accountId = loc.accountId ?? config.accountId;\n if (!accountId)\n return {\n key,\n skip: `no accountId — set site.reviews.accountId or locations.${key}.accountId`,\n };\n return { key, accountId, locationId: loc.locationId };\n });\n\n const blocked = locations.filter((l) => l.skip);\n if (blocked.length) for (const l of blocked) notes.push(`${l.key}: skipped — ${l.skip}`);\n\n return { locations, notes };\n}\n\n/** Render a plan as the human-readable block `--dry` prints. */\nexport function formatPlan(p: ReviewsPlan): string {\n if (p.blocked) return `✗ blocked — ${p.blocked}`;\n const lines: string[] = [];\n const active = p.locations.filter((l) => !l.skip);\n lines.push(`locations ${active.length} of ${p.locations.length} will be fetched`);\n for (const l of active) lines.push(` ${l.key} ${l.accountId}/${l.locationId}`);\n for (const n of p.notes) lines.push(` ! ${n}`);\n return lines.join('\\n');\n}\n\n/**\n * Filter and order one location's reviews for display.\n *\n * Featured ids always win and keep the author's order — an explicit choice\n * overrides any automatic ranking. The remainder is filtered by `minRating`\n * (default 4 — a star-rating widget showing a 1-star review it did not have to\n * is a design choice a config field should not have to spell out every time),\n * sorted, then capped by `limit`.\n */\nexport function selectReviews(\n reviews: Review[],\n // Partial: only the display fields are read. A renderer not tied to one configured location\n // (the EmDash block, which takes them from its own fields) has no `locationId` to supply.\n config: Partial<ReviewLocationConfig>,\n): Review[] {\n const featuredIds = config.featured ?? [];\n const byId = new Map(reviews.map((r) => [r.id, r]));\n const featured = featuredIds.map((id) => byId.get(id)).filter((r): r is Review => !!r);\n const featuredSet = new Set(featured.map((r) => r.id));\n\n const minRating = config.minRating ?? 4;\n const rest = reviews\n .filter((r) => !featuredSet.has(r.id) && r.rating >= minRating)\n .sort((a, b) =>\n config.sort === 'rating'\n ? b.rating - a.rating\n : new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime(),\n );\n\n const combined = [...featured, ...rest];\n return config.limit !== undefined ? combined.slice(0, config.limit) : combined;\n}\n\n/**\n * Merge a fresh fetch into the existing cache.\n *\n * Only the locations actually fetched this run are replaced; every other\n * location's prior reviews pass through untouched. This is what makes a partial\n * failure (one location's fetch errors) non-destructive — `cmdReviews` merges\n * only the locations that succeeded and leaves the rest of the cache alone.\n */\nexport function mergeCache(\n existing: ReviewCache | undefined,\n fetched: Record<string, LocationReviews>,\n): ReviewCache {\n return {\n version: 1,\n locations: { ...existing?.locations, ...fetched },\n };\n}\n"],"mappings":";;AA6CA,SAAgB,KAAK,OAA+B;CAClD,MAAM,EAAE,QAAQ,gBAAgB,cAAc,kBAAkB;CAChE,MAAM,QAAkB,CAAC;CAEzB,IAAI,CAAC,UAAU,OAAO,KAAK,OAAO,SAAS,CAAC,CAAC,WAAW,GACtD,OAAO;EACL,SAAS;EACT,WAAW,CAAC;EACZ;CACF;CAGF,IAAI,CAAC,eACH,OAAO;EACL,SACE;EACF,WAAW,CAAC;EACZ;CACF;CAGF,MAAM,UAAU,eACZ,OAAO,QAAQ,OAAO,SAAS,CAAC,CAAC,QAAQ,CAAC,SAAS,QAAQ,YAAY,IACvE,OAAO,QAAQ,OAAO,SAAS;CAEnC,IAAI,gBAAgB,QAAQ,WAAW,GACrC,OAAO;EACL,SAAS,eAAe,aAAa;EACrC,WAAW,CAAC;EACZ;CACF;CAGF,MAAM,YAA4B,QAAQ,KAAK,CAAC,KAAK,SAAS;EAC5D,IAAI,CAAC,eAAe,IAAI,GAAG,GACzB,OAAO;GACL;GACA,MAAM,8CAA8C,IAAI;EAC1D;EACF,MAAM,YAAY,IAAI,aAAa,OAAO;EAC1C,IAAI,CAAC,WACH,OAAO;GACL;GACA,MAAM,0DAA0D,IAAI;EACtE;EACF,OAAO;GAAE;GAAK;GAAW,YAAY,IAAI;EAAW;CACtD,CAAC;CAED,MAAM,UAAU,UAAU,QAAQ,MAAM,EAAE,IAAI;CAC9C,IAAI,QAAQ,QAAQ,KAAK,MAAM,KAAK,SAAS,MAAM,KAAK,GAAG,EAAE,IAAI,cAAc,EAAE,MAAM;CAEvF,OAAO;EAAE;EAAW;CAAM;AAC5B;;AAGA,SAAgB,WAAW,GAAwB;CACjD,IAAI,EAAE,SAAS,OAAO,eAAe,EAAE;CACvC,MAAM,QAAkB,CAAC;CACzB,MAAM,SAAS,EAAE,UAAU,QAAQ,MAAM,CAAC,EAAE,IAAI;CAChD,MAAM,KAAK,cAAc,OAAO,OAAO,MAAM,EAAE,UAAU,OAAO,iBAAiB;CACjF,KAAK,MAAM,KAAK,QAAQ,MAAM,KAAK,KAAK,EAAE,IAAI,IAAI,EAAE,UAAU,GAAG,EAAE,YAAY;CAC/E,KAAK,MAAM,KAAK,EAAE,OAAO,MAAM,KAAK,OAAO,GAAG;CAC9C,OAAO,MAAM,KAAK,IAAI;AACxB;;;;;;;;;;AAWA,SAAgB,cACd,SAGA,QACU;CACV,MAAM,cAAc,OAAO,YAAY,CAAC;CACxC,MAAM,OAAO,IAAI,IAAI,QAAQ,KAAK,MAAM,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC;CAClD,MAAM,WAAW,YAAY,KAAK,OAAO,KAAK,IAAI,EAAE,CAAC,CAAC,CAAC,QAAQ,MAAmB,CAAC,CAAC,CAAC;CACrF,MAAM,cAAc,IAAI,IAAI,SAAS,KAAK,MAAM,EAAE,EAAE,CAAC;CAErD,MAAM,YAAY,OAAO,aAAa;CACtC,MAAM,OAAO,QACV,QAAQ,MAAM,CAAC,YAAY,IAAI,EAAE,EAAE,KAAK,EAAE,UAAU,SAAS,CAAC,CAC9D,MAAM,GAAG,MACR,OAAO,SAAS,WACZ,EAAE,SAAS,EAAE,SACb,IAAI,KAAK,EAAE,WAAW,CAAC,CAAC,QAAQ,IAAI,IAAI,KAAK,EAAE,WAAW,CAAC,CAAC,QAAQ,CAC1E;CAEF,MAAM,WAAW,CAAC,GAAG,UAAU,GAAG,IAAI;CACtC,OAAO,OAAO,UAAU,SAAY,SAAS,MAAM,GAAG,OAAO,KAAK,IAAI;AACxE;;;;;;;;;AAUA,SAAgB,WACd,UACA,SACa;CACb,OAAO;EACL,SAAS;EACT,WAAW;GAAE,GAAG,UAAU;GAAW,GAAG;EAAQ;CAClD;AACF"}
@@ -1,85 +1,6 @@
1
1
  import { a as SCOPES, c as googleHeaders, i as GoogleOAuth, n as GoogleAuthLike, r as GoogleCredential, s as googleAccessToken, t as GoogleAuth } from "../token-CHR1O4w5.mjs";
2
2
  import { n as adcQuotaProject, r as parseAdcUser, t as adcCredentialsPath } from "../adc-DvX3-P7a.mjs";
3
- import { ReviewLocationConfig, ReviewsConfig } from "@getvitops/config";
4
- /** One review, as GBP returns it and the cache stores it. */
5
- interface Review {
6
- id: string;
7
- /** The `organization.locations[]` key this review belongs to. */
8
- location: string;
9
- /** 1–5. */
10
- rating: number;
11
- author: string;
12
- authorPhoto?: string | undefined;
13
- text?: string | undefined;
14
- publishedAt: string;
15
- updatedAt: string;
16
- reply?: {
17
- text: string;
18
- at: string;
19
- } | undefined;
20
- /** Link back to the review on Google — never omit when linking to it. */
21
- url?: string | undefined;
22
- }
23
- /** What a fetch of one location produced. */
24
- interface LocationReviews {
25
- locationId: string;
26
- accountId: string;
27
- fetchedAt: string;
28
- averageRating?: number | undefined;
29
- totalReviewCount?: number | undefined;
30
- reviews: Review[];
31
- }
32
- /** The on-disk cache, as written by `writeCache`. */
33
- interface ReviewCache {
34
- version: 1;
35
- locations: Record<string, LocationReviews>;
36
- }
37
- /** One location's fetch plan, or the reason it was skipped. */
38
- interface LocationPlan {
39
- key: string;
40
- accountId?: string;
41
- locationId?: string;
42
- /** Present iff this location will not be fetched. */
43
- skip?: string;
44
- }
45
- interface ReviewsPlan {
46
- /** Set when the whole run is refused — nothing is fetched. */
47
- blocked?: string;
48
- locations: LocationPlan[];
49
- notes: string[];
50
- }
51
- interface PlanInput {
52
- config: ReviewsConfig | undefined;
53
- /** Location keys with a Google listing configured (`organization.locations[].listings.google`). */
54
- knownLocations: Set<string>;
55
- /** `--location`: fetch exactly this one, skipping the rest. */
56
- onlyLocation?: string | undefined;
57
- /** Whether a Google credential was resolved. */
58
- hasCredential: boolean;
59
- }
60
- /** Decide which locations to fetch, and why any are skipped. */
61
- export declare function plan(input: PlanInput): ReviewsPlan;
62
- /** Render a plan as the human-readable block `--dry` prints. */
63
- export declare function formatPlan(p: ReviewsPlan): string;
64
- /**
65
- * Filter and order one location's reviews for display.
66
- *
67
- * Featured ids always win and keep the author's order — an explicit choice
68
- * overrides any automatic ranking. The remainder is filtered by `minRating`
69
- * (default 4 — a star-rating widget showing a 1-star review it did not have to
70
- * is a design choice a config field should not have to spell out every time),
71
- * sorted, then capped by `limit`.
72
- */
73
- export declare function selectReviews(reviews: Review[], config: ReviewLocationConfig): Review[];
74
- /**
75
- * Merge a fresh fetch into the existing cache.
76
- *
77
- * Only the locations actually fetched this run are replaced; every other
78
- * location's prior reviews pass through untouched. This is what makes a partial
79
- * failure (one location's fetch errors) non-destructive — `cmdReviews` merges
80
- * only the locations that succeeded and leaves the rest of the cache alone.
81
- */
82
- export declare function mergeCache(existing: ReviewCache | undefined, fetched: Record<string, LocationReviews>): ReviewCache;
3
+ import { a as mergeCache, c as LocationReviews, d as ReviewLocationConfig, f as ReviewsConfig, i as formatPlan, l as Review, n as PlanInput, o as plan, r as ReviewsPlan, s as selectReviews, t as LocationPlan, u as ReviewCache } from "../plan-CddqsmJJ.mjs";
83
4
  /** Default location, relative to the consumer's project root. */
84
5
  export declare const REVIEWS_CACHE_PATH = ".vitops/reviews.json";
85
6
  /**
@@ -112,5 +33,5 @@ interface FetchReviewsResult {
112
33
  * `../indexing/gsc.ts`'s `inspectUrl` keeps.
113
34
  */
114
35
  export declare function fetchReviews(auth: GoogleAuthLike, accountId: string, locationId: string, fetchImpl?: typeof fetch): Promise<FetchReviewsResult>;
115
- export { type FetchReviewsResult, type GoogleAuth, type GoogleAuthLike, type GoogleCredential, type GoogleOAuth, type LocationPlan, type LocationReviews, type PlanInput, type Review, type ReviewCache, type ReviewLocationConfig, type ReviewsConfig, type ReviewsPlan, SCOPES, adcCredentialsPath, adcQuotaProject, googleAccessToken, googleHeaders, parseAdcUser };
36
+ export { type FetchReviewsResult, type GoogleAuth, type GoogleAuthLike, type GoogleCredential, type GoogleOAuth, type LocationPlan, type LocationReviews, type PlanInput, type Review, type ReviewCache, type ReviewLocationConfig, type ReviewsConfig, type ReviewsPlan, SCOPES, adcCredentialsPath, adcQuotaProject, formatPlan, googleAccessToken, googleHeaders, mergeCache, parseAdcUser, plan, selectReviews };
116
37
  //# sourceMappingURL=index.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/reviews/types.ts","../../src/reviews/plan.ts","../../src/reviews/cache.ts","../../src/reviews/gbp.ts"],"mappings":";;;;UAkBiB;EACf;;EAEA;;EAEA;EACA;EACA;EACA;EACA;EACA;EACA;IAAU;IAAc;;;EAExB;;;UAIe;EACf;EACA;EACA;EACA;EACA;EACA,SAAS;;;UAIM;EACf;EACA,WAAW,eAAe;;;UC5BX;EACf;EACA;EACA;;EAEA;;UAGe;;EAEf;EACA,WAAW;EACX;;UAGe;EACf,QAAQ;;EAER,gBAAgB;;EAEhB;;EAEA;;;wBAIc,KAAK,OAAO,YAAY;;wBAuDxB,WAAW,GAAG;;;;;;;;;;wBAmBd,cAAc,SAAS,UAAU,QAAQ,uBAAuB;;;;;;;;;wBA2BhE,WACd,UAAU,yBACV,SAAS,eAAe,mBACvB;;qBCvIU;;;;;;;;;wBAUG,UAAU,eAAe;;wBAKzB,QAAQ,WAAW,eAAe,mBAAmB;;wBAarD,WAAW,cAAc,OAAO;;UCoB/B;EACf;EACA,SAAS;EACT;EACA;EACA;EACA;;;;;;;;;wBAUoB,aACpB,MAAM,gBACN,mBACA,oBACA,mBAAkB,QACjB,QAAQ"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/reviews/cache.ts","../../src/reviews/gbp.ts"],"mappings":";;;;qBAca;;;;;;;;;wBAUG,UAAU,eAAe;;wBAKzB,QAAQ,WAAW,eAAe,mBAAmB;;wBAarD,WAAW,cAAc,OAAO;;UCoB/B;EACf;EACA,SAAS;EACT;EACA;EACA;EACA;;;;;;;;;wBAUoB,aACpB,MAAM,gBACN,mBACA,oBACA,mBAAkB,QACjB,QAAQ"}
@@ -1,97 +1,7 @@
1
1
  import { n as writeVersioned, t as readVersioned } from "../json-store-VJKEaoVX.mjs";
2
2
  import { a as googleHeaders, i as googleAccessToken, t as SCOPES } from "../token-CJSl9eIS.mjs";
3
3
  import { n as adcQuotaProject, r as parseAdcUser, t as adcCredentialsPath } from "../adc-BiW9BzqA.mjs";
4
-
5
- // src/reviews/plan.ts
6
- /** Decide which locations to fetch, and why any are skipped. */
7
- function plan(input) {
8
- const { config, knownLocations, onlyLocation, hasCredential } = input;
9
- const notes = [];
10
- if (!config || Object.keys(config.locations).length === 0) return {
11
- blocked: "no site.reviews.locations configured — nothing to fetch",
12
- locations: [],
13
- notes
14
- };
15
- if (!hasCredential) return {
16
- blocked: "no Google credential — set VITOPS_GOOGLE_CLIENT_ID / _CLIENT_SECRET / _REFRESH_TOKEN, or run `gcloud auth application-default login`",
17
- locations: [],
18
- notes
19
- };
20
- const entries = onlyLocation ? Object.entries(config.locations).filter(([key]) => key === onlyLocation) : Object.entries(config.locations);
21
- if (onlyLocation && entries.length === 0) return {
22
- blocked: `--location "${onlyLocation}" is not in site.reviews.locations`,
23
- locations: [],
24
- notes
25
- };
26
- const locations = entries.map(([key, loc]) => {
27
- if (!knownLocations.has(key)) return {
28
- key,
29
- skip: `organization.locations has no entry keyed "${key}" with a listings.google id`
30
- };
31
- const accountId = loc.accountId ?? config.accountId;
32
- if (!accountId) return {
33
- key,
34
- skip: `no accountId — set site.reviews.accountId or locations.${key}.accountId`
35
- };
36
- return {
37
- key,
38
- accountId,
39
- locationId: loc.locationId
40
- };
41
- });
42
- const blocked = locations.filter((l) => l.skip);
43
- if (blocked.length) for (const l of blocked) notes.push(`${l.key}: skipped — ${l.skip}`);
44
- return {
45
- locations,
46
- notes
47
- };
48
- }
49
- /** Render a plan as the human-readable block `--dry` prints. */
50
- function formatPlan(p) {
51
- if (p.blocked) return `✗ blocked — ${p.blocked}`;
52
- const lines = [];
53
- const active = p.locations.filter((l) => !l.skip);
54
- lines.push(`locations ${active.length} of ${p.locations.length} will be fetched`);
55
- for (const l of active) lines.push(` ${l.key} ${l.accountId}/${l.locationId}`);
56
- for (const n of p.notes) lines.push(` ! ${n}`);
57
- return lines.join("\n");
58
- }
59
- /**
60
- * Filter and order one location's reviews for display.
61
- *
62
- * Featured ids always win and keep the author's order — an explicit choice
63
- * overrides any automatic ranking. The remainder is filtered by `minRating`
64
- * (default 4 — a star-rating widget showing a 1-star review it did not have to
65
- * is a design choice a config field should not have to spell out every time),
66
- * sorted, then capped by `limit`.
67
- */
68
- function selectReviews(reviews, config) {
69
- const featuredIds = config.featured ?? [];
70
- const byId = new Map(reviews.map((r) => [r.id, r]));
71
- const featured = featuredIds.map((id) => byId.get(id)).filter((r) => !!r);
72
- const featuredSet = new Set(featured.map((r) => r.id));
73
- const minRating = config.minRating ?? 4;
74
- const rest = reviews.filter((r) => !featuredSet.has(r.id) && r.rating >= minRating).sort((a, b) => config.sort === "rating" ? b.rating - a.rating : new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime());
75
- const combined = [...featured, ...rest];
76
- return config.limit !== void 0 ? combined.slice(0, config.limit) : combined;
77
- }
78
- /**
79
- * Merge a fresh fetch into the existing cache.
80
- *
81
- * Only the locations actually fetched this run are replaced; every other
82
- * location's prior reviews pass through untouched. This is what makes a partial
83
- * failure (one location's fetch errors) non-destructive — `cmdReviews` merges
84
- * only the locations that succeeded and leaves the rest of the cache alone.
85
- */
86
- function mergeCache(existing, fetched) {
87
- return {
88
- version: 1,
89
- locations: {
90
- ...existing?.locations,
91
- ...fetched
92
- }
93
- };
94
- }
4
+ import { i as selectReviews, n as mergeCache, r as plan, t as formatPlan } from "../plan-DpHv6NO8.mjs";
95
5
 
96
6
  // src/reviews/cache.ts
97
7
  /** Default location, relative to the consumer's project root. */
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../../src/reviews/plan.ts","../../src/reviews/cache.ts","../../src/reviews/gbp.ts"],"sourcesContent":["/**\n * Every decision `vitops reviews` makes, as pure functions.\n *\n * Same split as `../indexing/plan.ts`: no `fetch`, no `fs`, no clock. Which\n * locations to fetch (and why one is blocked), and how a location's reviews are\n * filtered down to what a site actually displays, are decided here — `gbp.ts`\n * and `cache.ts` do what they're told and nothing else. That is what lets Astro's\n * `<ReviewCard>` and a future Bricks renderer both call `selectReviews` instead\n * of each re-implementing \"top 5 by rating, minus the ones already featured\".\n */\nimport type {\n LocationReviews,\n Review,\n ReviewCache,\n ReviewLocationConfig,\n ReviewsConfig,\n} from './types.ts';\n\n/** One location's fetch plan, or the reason it was skipped. */\nexport interface LocationPlan {\n key: string;\n accountId?: string;\n locationId?: string;\n /** Present iff this location will not be fetched. */\n skip?: string;\n}\n\nexport interface ReviewsPlan {\n /** Set when the whole run is refused — nothing is fetched. */\n blocked?: string;\n locations: LocationPlan[];\n notes: string[];\n}\n\nexport interface PlanInput {\n config: ReviewsConfig | undefined;\n /** Location keys with a Google listing configured (`organization.locations[].listings.google`). */\n knownLocations: Set<string>;\n /** `--location`: fetch exactly this one, skipping the rest. */\n onlyLocation?: string | undefined;\n /** Whether a Google credential was resolved. */\n hasCredential: boolean;\n}\n\n/** Decide which locations to fetch, and why any are skipped. */\nexport function plan(input: PlanInput): ReviewsPlan {\n const { config, knownLocations, onlyLocation, hasCredential } = input;\n const notes: string[] = [];\n\n if (!config || Object.keys(config.locations).length === 0) {\n return {\n blocked: 'no site.reviews.locations configured — nothing to fetch',\n locations: [],\n notes,\n };\n }\n\n if (!hasCredential) {\n return {\n blocked:\n 'no Google credential — set VITOPS_GOOGLE_CLIENT_ID / _CLIENT_SECRET / _REFRESH_TOKEN, or run `gcloud auth application-default login`',\n locations: [],\n notes,\n };\n }\n\n const entries = onlyLocation\n ? Object.entries(config.locations).filter(([key]) => key === onlyLocation)\n : Object.entries(config.locations);\n\n if (onlyLocation && entries.length === 0) {\n return {\n blocked: `--location \"${onlyLocation}\" is not in site.reviews.locations`,\n locations: [],\n notes,\n };\n }\n\n const locations: LocationPlan[] = entries.map(([key, loc]) => {\n if (!knownLocations.has(key))\n return {\n key,\n skip: `organization.locations has no entry keyed \"${key}\" with a listings.google id`,\n };\n const accountId = loc.accountId ?? config.accountId;\n if (!accountId)\n return {\n key,\n skip: `no accountId — set site.reviews.accountId or locations.${key}.accountId`,\n };\n return { key, accountId, locationId: loc.locationId };\n });\n\n const blocked = locations.filter((l) => l.skip);\n if (blocked.length) for (const l of blocked) notes.push(`${l.key}: skipped — ${l.skip}`);\n\n return { locations, notes };\n}\n\n/** Render a plan as the human-readable block `--dry` prints. */\nexport function formatPlan(p: ReviewsPlan): string {\n if (p.blocked) return `✗ blocked — ${p.blocked}`;\n const lines: string[] = [];\n const active = p.locations.filter((l) => !l.skip);\n lines.push(`locations ${active.length} of ${p.locations.length} will be fetched`);\n for (const l of active) lines.push(` ${l.key} ${l.accountId}/${l.locationId}`);\n for (const n of p.notes) lines.push(` ! ${n}`);\n return lines.join('\\n');\n}\n\n/**\n * Filter and order one location's reviews for display.\n *\n * Featured ids always win and keep the author's order — an explicit choice\n * overrides any automatic ranking. The remainder is filtered by `minRating`\n * (default 4 — a star-rating widget showing a 1-star review it did not have to\n * is a design choice a config field should not have to spell out every time),\n * sorted, then capped by `limit`.\n */\nexport function selectReviews(reviews: Review[], config: ReviewLocationConfig): Review[] {\n const featuredIds = config.featured ?? [];\n const byId = new Map(reviews.map((r) => [r.id, r]));\n const featured = featuredIds.map((id) => byId.get(id)).filter((r): r is Review => !!r);\n const featuredSet = new Set(featured.map((r) => r.id));\n\n const minRating = config.minRating ?? 4;\n const rest = reviews\n .filter((r) => !featuredSet.has(r.id) && r.rating >= minRating)\n .sort((a, b) =>\n config.sort === 'rating'\n ? b.rating - a.rating\n : new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime(),\n );\n\n const combined = [...featured, ...rest];\n return config.limit !== undefined ? combined.slice(0, config.limit) : combined;\n}\n\n/**\n * Merge a fresh fetch into the existing cache.\n *\n * Only the locations actually fetched this run are replaced; every other\n * location's prior reviews pass through untouched. This is what makes a partial\n * failure (one location's fetch errors) non-destructive — `cmdReviews` merges\n * only the locations that succeeded and leaves the rest of the cache alone.\n */\nexport function mergeCache(\n existing: ReviewCache | undefined,\n fetched: Record<string, LocationReviews>,\n): ReviewCache {\n return {\n version: 1,\n locations: { ...existing?.locations, ...fetched },\n };\n}\n","/**\n * The cache — what makes a re-render free and offline.\n *\n * Committed, like `../media/manifest.ts` and for the same reason: reviews change\n * slowly, and a build should not need a live Google credential to render them. The\n * one field that keeps it from being byte-identical across runs is `fetchedAt` per\n * location — acceptable because `vitops reviews` is invoked deliberately\n * (scheduled or manual), not rewritten on every build the way `media-manifest.json`\n * is.\n */\nimport type { LocationReviews, ReviewCache } from './types.ts';\nimport { readVersioned, writeVersioned } from '../json-store.ts';\n\n/** Default location, relative to the consumer's project root. */\nexport const REVIEWS_CACHE_PATH = '.vitops/reviews.json';\n\n/**\n * Read the cache, or `undefined` when there isn't a usable one.\n *\n * A corrupt or wrong-version file reads as absent, exactly `readManifest`'s rule:\n * the alternative — reading it as an empty cache — would be indistinguishable\n * from \"this business genuinely has no reviews\" and silently ship an empty\n * carousel.\n */\nexport function readCache(path: string): ReviewCache | undefined {\n return readVersioned<ReviewCache, 'locations'>(path, 'locations');\n}\n\n/** Build a cache with its keys and each location's reviews in a stable order. */\nexport function toCache(locations: Record<string, LocationReviews>): ReviewCache {\n const sorted: Record<string, LocationReviews> = {};\n for (const key of Object.keys(locations).sort()) {\n const entry = locations[key] as LocationReviews;\n sorted[key] = {\n ...entry,\n reviews: [...entry.reviews].sort((a, b) => a.id.localeCompare(b.id)),\n };\n }\n return { version: 1, locations: sorted };\n}\n\n/** Write the cache, creating its directory. */\nexport function writeCache(path: string, cache: ReviewCache): void {\n writeVersioned(path, cache);\n}\n","/**\n * Google Business Profile — fetching one location's reviews.\n *\n * Reviews live only on the legacy v4 Business Information API\n * (`mybusiness.googleapis.com/v4`); the newer split APIs (Account Management,\n * Business Information v1) never grew a reviews resource. **The calling GCP\n * project must be allowlisted by Google before any call here succeeds** — a\n * one-time access-request form, turnaround measured in days to weeks — so a 403\n * is treated as that known state rather than surfaced as a raw failure. Scope:\n * `SCOPES.businessProfile` (`../google/token.ts`).\n *\n * TODO — re-check when Google migrates the Reviews endpoint off v4 onto a\n * successor API. Endpoint, auth, pagination and the review object's fields may\n * all change; this module is the one place that assumption lives.\n *\n * Auth is the shared exchange in `../google/token.ts`, same as `../indexing/gsc.ts`\n * — no `googleapis` dependency, one REST endpoint.\n */\nimport { googleHeaders, type GoogleAuthLike } from '../google/token.ts';\nimport type { Review } from './types.ts';\n\nconst STAR_RATING: Record<string, number> = {\n ONE: 1,\n TWO: 2,\n THREE: 3,\n FOUR: 4,\n FIVE: 5,\n};\n\ninterface RawReview {\n reviewId: string;\n reviewer?: { displayName?: string; profilePhotoUrl?: string; isAnonymous?: boolean };\n starRating?: string;\n comment?: string;\n createTime: string;\n updateTime: string;\n reviewReply?: { comment: string; updateTime: string };\n}\n\nfunction toReview(raw: RawReview, location: string): Review {\n return {\n id: raw.reviewId,\n location,\n rating: STAR_RATING[raw.starRating ?? ''] ?? 0,\n author: raw.reviewer?.isAnonymous\n ? 'A Google user'\n : (raw.reviewer?.displayName ?? 'A Google user'),\n ...(raw.reviewer?.profilePhotoUrl ? { authorPhoto: raw.reviewer.profilePhotoUrl } : {}),\n ...(raw.comment ? { text: raw.comment } : {}),\n publishedAt: raw.createTime,\n updatedAt: raw.updateTime,\n ...(raw.reviewReply\n ? { reply: { text: raw.reviewReply.comment, at: raw.reviewReply.updateTime } }\n : {}),\n // v4 carries no public review URL on the resource itself. The stable link is\n // `organization.locations[].listings.google` / `sameAs` — the whole profile,\n // not the single review — which is what a \"read more on Google\" affordance\n // should point at instead.\n };\n}\n\n/** One page (or, having paginated, the whole set) of a location's reviews. */\nexport interface FetchReviewsResult {\n ok: boolean;\n reviews: Review[];\n averageRating?: number;\n totalReviewCount?: number;\n status?: number;\n message?: string;\n}\n\n/**\n * Fetch every review for one location, paginating to completion.\n *\n * Never throws — a per-location failure is a structured result so a run across\n * several locations can fetch what it can and report the rest, the same contract\n * `../indexing/gsc.ts`'s `inspectUrl` keeps.\n */\nexport async function fetchReviews(\n auth: GoogleAuthLike,\n accountId: string,\n locationId: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<FetchReviewsResult> {\n const location = locationId.startsWith('locations/') ? locationId : `locations/${locationId}`;\n const account = accountId.startsWith('accounts/') ? accountId : `accounts/${accountId}`;\n const reviews: Review[] = [];\n let averageRating: number | undefined;\n let totalReviewCount: number | undefined;\n let pageToken: string | undefined;\n\n do {\n const url = new URL(`https://mybusiness.googleapis.com/v4/${account}/${location}/reviews`);\n url.searchParams.set('pageSize', '50');\n if (pageToken) url.searchParams.set('pageToken', pageToken);\n\n const res = await fetchImpl(url, { headers: googleHeaders(auth) });\n if (!res.ok) {\n const message =\n res.status === 403\n ? `403 — this GCP project is most likely not allowlisted for the Business Profile API yet ` +\n `(a one-time Google approval, requested separately from every other API here), or the ` +\n `credential is not an owner/manager of ${account}/${location}`\n : `${res.status}: ${await res.text()}`;\n return { ok: false, reviews, status: res.status, message };\n }\n\n const body = (await res.json()) as {\n reviews?: RawReview[];\n averageRating?: number;\n totalReviewCount?: number;\n nextPageToken?: string;\n };\n for (const r of body.reviews ?? []) reviews.push(toReview(r, locationId));\n averageRating = body.averageRating ?? averageRating;\n totalReviewCount = body.totalReviewCount ?? totalReviewCount;\n pageToken = body.nextPageToken;\n } while (pageToken);\n\n return {\n ok: true,\n reviews,\n ...(averageRating !== undefined ? { averageRating } : {}),\n ...(totalReviewCount !== undefined ? { totalReviewCount } : {}),\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../src/reviews/cache.ts","../../src/reviews/gbp.ts"],"sourcesContent":["/**\n * The cache — what makes a re-render free and offline.\n *\n * Committed, like `../media/manifest.ts` and for the same reason: reviews change\n * slowly, and a build should not need a live Google credential to render them. The\n * one field that keeps it from being byte-identical across runs is `fetchedAt` per\n * location — acceptable because `vitops reviews` is invoked deliberately\n * (scheduled or manual), not rewritten on every build the way `media-manifest.json`\n * is.\n */\nimport type { LocationReviews, ReviewCache } from './types.ts';\nimport { readVersioned, writeVersioned } from '../json-store.ts';\n\n/** Default location, relative to the consumer's project root. */\nexport const REVIEWS_CACHE_PATH = '.vitops/reviews.json';\n\n/**\n * Read the cache, or `undefined` when there isn't a usable one.\n *\n * A corrupt or wrong-version file reads as absent, exactly `readManifest`'s rule:\n * the alternative — reading it as an empty cache — would be indistinguishable\n * from \"this business genuinely has no reviews\" and silently ship an empty\n * carousel.\n */\nexport function readCache(path: string): ReviewCache | undefined {\n return readVersioned<ReviewCache, 'locations'>(path, 'locations');\n}\n\n/** Build a cache with its keys and each location's reviews in a stable order. */\nexport function toCache(locations: Record<string, LocationReviews>): ReviewCache {\n const sorted: Record<string, LocationReviews> = {};\n for (const key of Object.keys(locations).sort()) {\n const entry = locations[key] as LocationReviews;\n sorted[key] = {\n ...entry,\n reviews: [...entry.reviews].sort((a, b) => a.id.localeCompare(b.id)),\n };\n }\n return { version: 1, locations: sorted };\n}\n\n/** Write the cache, creating its directory. */\nexport function writeCache(path: string, cache: ReviewCache): void {\n writeVersioned(path, cache);\n}\n","/**\n * Google Business Profile — fetching one location's reviews.\n *\n * Reviews live only on the legacy v4 Business Information API\n * (`mybusiness.googleapis.com/v4`); the newer split APIs (Account Management,\n * Business Information v1) never grew a reviews resource. **The calling GCP\n * project must be allowlisted by Google before any call here succeeds** — a\n * one-time access-request form, turnaround measured in days to weeks — so a 403\n * is treated as that known state rather than surfaced as a raw failure. Scope:\n * `SCOPES.businessProfile` (`../google/token.ts`).\n *\n * TODO — re-check when Google migrates the Reviews endpoint off v4 onto a\n * successor API. Endpoint, auth, pagination and the review object's fields may\n * all change; this module is the one place that assumption lives.\n *\n * Auth is the shared exchange in `../google/token.ts`, same as `../indexing/gsc.ts`\n * — no `googleapis` dependency, one REST endpoint.\n */\nimport { googleHeaders, type GoogleAuthLike } from '../google/token.ts';\nimport type { Review } from './types.ts';\n\nconst STAR_RATING: Record<string, number> = {\n ONE: 1,\n TWO: 2,\n THREE: 3,\n FOUR: 4,\n FIVE: 5,\n};\n\ninterface RawReview {\n reviewId: string;\n reviewer?: { displayName?: string; profilePhotoUrl?: string; isAnonymous?: boolean };\n starRating?: string;\n comment?: string;\n createTime: string;\n updateTime: string;\n reviewReply?: { comment: string; updateTime: string };\n}\n\nfunction toReview(raw: RawReview, location: string): Review {\n return {\n id: raw.reviewId,\n location,\n rating: STAR_RATING[raw.starRating ?? ''] ?? 0,\n author: raw.reviewer?.isAnonymous\n ? 'A Google user'\n : (raw.reviewer?.displayName ?? 'A Google user'),\n ...(raw.reviewer?.profilePhotoUrl ? { authorPhoto: raw.reviewer.profilePhotoUrl } : {}),\n ...(raw.comment ? { text: raw.comment } : {}),\n publishedAt: raw.createTime,\n updatedAt: raw.updateTime,\n ...(raw.reviewReply\n ? { reply: { text: raw.reviewReply.comment, at: raw.reviewReply.updateTime } }\n : {}),\n // v4 carries no public review URL on the resource itself. The stable link is\n // `organization.locations[].listings.google` / `sameAs` — the whole profile,\n // not the single review — which is what a \"read more on Google\" affordance\n // should point at instead.\n };\n}\n\n/** One page (or, having paginated, the whole set) of a location's reviews. */\nexport interface FetchReviewsResult {\n ok: boolean;\n reviews: Review[];\n averageRating?: number;\n totalReviewCount?: number;\n status?: number;\n message?: string;\n}\n\n/**\n * Fetch every review for one location, paginating to completion.\n *\n * Never throws — a per-location failure is a structured result so a run across\n * several locations can fetch what it can and report the rest, the same contract\n * `../indexing/gsc.ts`'s `inspectUrl` keeps.\n */\nexport async function fetchReviews(\n auth: GoogleAuthLike,\n accountId: string,\n locationId: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<FetchReviewsResult> {\n const location = locationId.startsWith('locations/') ? locationId : `locations/${locationId}`;\n const account = accountId.startsWith('accounts/') ? accountId : `accounts/${accountId}`;\n const reviews: Review[] = [];\n let averageRating: number | undefined;\n let totalReviewCount: number | undefined;\n let pageToken: string | undefined;\n\n do {\n const url = new URL(`https://mybusiness.googleapis.com/v4/${account}/${location}/reviews`);\n url.searchParams.set('pageSize', '50');\n if (pageToken) url.searchParams.set('pageToken', pageToken);\n\n const res = await fetchImpl(url, { headers: googleHeaders(auth) });\n if (!res.ok) {\n const message =\n res.status === 403\n ? `403 — this GCP project is most likely not allowlisted for the Business Profile API yet ` +\n `(a one-time Google approval, requested separately from every other API here), or the ` +\n `credential is not an owner/manager of ${account}/${location}`\n : `${res.status}: ${await res.text()}`;\n return { ok: false, reviews, status: res.status, message };\n }\n\n const body = (await res.json()) as {\n reviews?: RawReview[];\n averageRating?: number;\n totalReviewCount?: number;\n nextPageToken?: string;\n };\n for (const r of body.reviews ?? []) reviews.push(toReview(r, locationId));\n averageRating = body.averageRating ?? averageRating;\n totalReviewCount = body.totalReviewCount ?? totalReviewCount;\n pageToken = body.nextPageToken;\n } while (pageToken);\n\n return {\n ok: true,\n reviews,\n ...(averageRating !== undefined ? { averageRating } : {}),\n ...(totalReviewCount !== undefined ? { totalReviewCount } : {}),\n };\n}\n"],"mappings":";;;;;;;AAcA,MAAa,qBAAqB;;;;;;;;;AAUlC,SAAgB,UAAU,MAAuC;CAC/D,OAAO,cAAwC,MAAM,WAAW;AAClE;;AAGA,SAAgB,QAAQ,WAAyD;CAC/E,MAAM,SAA0C,CAAC;CACjD,KAAK,MAAM,OAAO,OAAO,KAAK,SAAS,CAAC,CAAC,KAAK,GAAG;EAC/C,MAAM,QAAQ,UAAU;EACxB,OAAO,OAAO;GACZ,GAAG;GACH,SAAS,CAAC,GAAG,MAAM,OAAO,CAAC,CAAC,MAAM,GAAG,MAAM,EAAE,GAAG,cAAc,EAAE,EAAE,CAAC;EACrE;CACF;CACA,OAAO;EAAE,SAAS;EAAG,WAAW;CAAO;AACzC;;AAGA,SAAgB,WAAW,MAAc,OAA0B;CACjE,eAAe,MAAM,KAAK;AAC5B;;;;;;;;;;;;;;;;;;;;;ACvBA,MAAM,cAAsC;CAC1C,KAAK;CACL,KAAK;CACL,OAAO;CACP,MAAM;CACN,MAAM;AACR;AAYA,SAAS,SAAS,KAAgB,UAA0B;CAC1D,OAAO;EACL,IAAI,IAAI;EACR;EACA,QAAQ,YAAY,IAAI,cAAc,OAAO;EAC7C,QAAQ,IAAI,UAAU,cAClB,kBACC,IAAI,UAAU,eAAe;EAClC,GAAI,IAAI,UAAU,kBAAkB,EAAE,aAAa,IAAI,SAAS,gBAAgB,IAAI,CAAC;EACrF,GAAI,IAAI,UAAU,EAAE,MAAM,IAAI,QAAQ,IAAI,CAAC;EAC3C,aAAa,IAAI;EACjB,WAAW,IAAI;EACf,GAAI,IAAI,cACJ,EAAE,OAAO;GAAE,MAAM,IAAI,YAAY;GAAS,IAAI,IAAI,YAAY;EAAW,EAAE,IAC3E,CAAC;CAKP;AACF;;;;;;;;AAmBA,eAAsB,aACpB,MACA,WACA,YACA,YAA0B,OACG;CAC7B,MAAM,WAAW,WAAW,WAAW,YAAY,IAAI,aAAa,aAAa;CACjF,MAAM,UAAU,UAAU,WAAW,WAAW,IAAI,YAAY,YAAY;CAC5E,MAAM,UAAoB,CAAC;CAC3B,IAAI;CACJ,IAAI;CACJ,IAAI;CAEJ,GAAG;EACD,MAAM,MAAM,IAAI,IAAI,wCAAwC,QAAQ,GAAG,SAAS,SAAS;EACzF,IAAI,aAAa,IAAI,YAAY,IAAI;EACrC,IAAI,WAAW,IAAI,aAAa,IAAI,aAAa,SAAS;EAE1D,MAAM,MAAM,MAAM,UAAU,KAAK,EAAE,SAAS,cAAc,IAAI,EAAE,CAAC;EACjE,IAAI,CAAC,IAAI,IAAI;GACX,MAAM,UACJ,IAAI,WAAW,MACX,qNAEyC,QAAQ,GAAG,aACpD,GAAG,IAAI,OAAO,IAAI,MAAM,IAAI,KAAK;GACvC,OAAO;IAAE,IAAI;IAAO;IAAS,QAAQ,IAAI;IAAQ;GAAQ;EAC3D;EAEA,MAAM,OAAQ,MAAM,IAAI,KAAK;EAM7B,KAAK,MAAM,KAAK,KAAK,WAAW,CAAC,GAAG,QAAQ,KAAK,SAAS,GAAG,UAAU,CAAC;EACxE,gBAAgB,KAAK,iBAAiB;EACtC,mBAAmB,KAAK,oBAAoB;EAC5C,YAAY,KAAK;CACnB,SAAS;CAET,OAAO;EACL,IAAI;EACJ;EACA,GAAI,kBAAkB,SAAY,EAAE,cAAc,IAAI,CAAC;EACvD,GAAI,qBAAqB,SAAY,EAAE,iBAAiB,IAAI,CAAC;CAC/D;AACF"}
@@ -0,0 +1,2 @@
1
+ import { c as LocationReviews, d as ReviewLocationConfig, f as ReviewsConfig, l as Review, s as selectReviews, u as ReviewCache } from "../plan-CddqsmJJ.mjs";
2
+ export { type LocationReviews, type Review, type ReviewCache, type ReviewLocationConfig, type ReviewsConfig, selectReviews };