spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1"use client";23import { Plus, Trash2 } from "lucide-react";4import { useMemo, useState } from "react";5import { Badge, Button } from "@/components/ui";6import { cn } from "@/components/ui/cn";7import { Checkbox, Code, ErrorNote, Field, Input, Select, Textarea } from "../primitives";8import { toYaml } from "../yaml";9import { COMMON_FIELDS, configToRule, EXTRACT_KINDS, extractorToConfig, newFieldRule, RULE_TYPES, TRANSFORMS, type DevtoolShared, type FieldRule } from "./state";1011export function StepRules({ s }: { s: DevtoolShared }) {12 const { draft, update } = s;13 const ex = draft.extractor;14 const [advanced, setAdvanced] = useState(false);15 const [jsonText, setJsonText] = useState("");16 const [jsonError, setJsonError] = useState<string | null>(null);17 const config = useMemo(() => extractorToConfig(ex, { includeMatch: true }), [ex]);18 const yamlPreview = useMemo(() => toYaml({ extractors: { [ex.name.trim() || ex.kind]: config } }), [config, ex.name, ex.kind]);19 const setEx = (patch: Partial<typeof ex>) => update({ extractor: { ...ex, ...patch }, yamlDirty: draft.yamlDirty });20 const setField = (id: string, patch: Partial<FieldRule>) => setEx({ fields: ex.fields.map((f) => (f.id === id ? { ...f, ...patch } : f)) });21 const suggestions = COMMON_FIELDS[ex.kind] ?? [];22 const usedNames = new Set(ex.fields.map((f) => f.name.trim()).filter(Boolean));23 const duplicates = ex.fields.map((f) => f.name.trim()).filter((n, i, a) => n && a.indexOf(n) !== i);2425 const openAdvanced = () => {26 setJsonText(JSON.stringify(config, null, 2));27 setJsonError(null);28 setAdvanced(true);29 };30 const applyAdvanced = () => {31 try {32 const o = JSON.parse(jsonText) as Record<string, unknown>;33 const fields = Object.entries((o.fields ?? {}) as Record<string, unknown>).map(([n, r]) => configToRule(n, r));34 setEx({ kind: typeof o.kind === "string" ? o.kind : ex.kind, key: typeof o.key === "string" ? o.key : "", each: typeof o.each === "string" ? o.each : "", parser: typeof o.parser === "string" ? o.parser : "", minCertainty: o.minCertainty != null ? String(o.minCertainty) : "", match: Array.isArray(o.match) ? (o.match as string[]).join("\n") : ex.match, fields });35 setAdvanced(false);36 setJsonError(null);37 } catch (e) {38 setJsonError((e as Error).message);39 }40 };4142 return (43 <div className="grid gap-3 xl:grid-cols-[1fr_380px]">44 <div className="space-y-3">45 <div className="rounded-sm border border-hair bg-surface p-3">46 <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">47 <Field label="Extractor name" hint="key under extractors:">48 <Input value={ex.name} onChange={(e) => setEx({ name: e.target.value.replace(/[^a-z0-9_-]/gi, "") })} mono placeholder="facility" />49 </Field>50 <Field label="Entity kind">51 <Select value={ex.kind} onChange={(e) => setEx({ kind: e.target.value })}>52 {EXTRACT_KINDS.map((k) => (53 <option key={k} value={k}>54 {k}55 </option>56 ))}57 </Select>58 </Field>59 <Field label="Key template" hint="stable id: {url}, {code}, {name}">60 <Input value={ex.key} onChange={(e) => setEx({ key: e.target.value })} mono placeholder="{url}" />61 </Field>62 <Field label="each (list container)" hint="one record per match">63 <Input value={ex.each} onChange={(e) => setEx({ each: e.target.value })} mono placeholder=".facility-card" />64 </Field>65 <Field label="Min certainty" hint="0–1, optional">66 <Input value={ex.minCertainty} onChange={(e) => setEx({ minCertainty: e.target.value })} mono placeholder="0.5" inputMode="decimal" />67 </Field>68 </div>69 <div className="mt-3 grid gap-3 sm:grid-cols-2">70 <Field label="URL match patterns" hint="regex per line — which pages this extractor handles (used when saving; preview ignores it)">71 <Textarea rows={2} value={ex.match} onChange={(e) => setEx({ match: e.target.value })} mono placeholder={"/data-centers/[a-z-]+/[a-z-]+/[a-z0-9-]+$"} />72 </Field>73 <Field label="Code parser (optional)" hint="registered parser name (apps/worker/src/parsers) — overrides declarative fields">74 <Input value={ex.parser} onChange={(e) => setEx({ parser: e.target.value })} mono placeholder="digitalrealty_facility_v1" />75 </Field>76 </div>77 </div>7879 <div className="rounded-sm border border-hair bg-surface">80 <div className="flex flex-wrap items-center justify-between gap-2 border-b border-hair px-3 py-2">81 <div className="text-[13px] font-semibold text-ink">82 Fields <span className="ml-1 text-[12px] font-normal text-ink-3">{ex.fields.length}</span>83 </div>84 <div className="flex flex-wrap items-center gap-1.5">85 {suggestions86 .filter((n) => !usedNames.has(n))87 .slice(0, 12)88 .map((n) => (89 <button key={n} type="button" onClick={() => setEx({ fields: [...ex.fields, newFieldRule({ name: n, type: n === "lat" || n === "lng" ? "jsonld" : "selector", jsonld: n === "lat" || n === "lng" ? "Place" : "", jsonPath: n === "lat" ? "$.geo.latitude" : n === "lng" ? "$.geo.longitude" : "", transform: /Mw$/.test(n) || n === "mw" ? "mw" : n === "openedOn" || n === "launchedOn" || n === "announcedOn" ? "date" : n === "status" ? "status" : n === "countryIso2" ? "country" : n === "buildingSqm" ? "sqm" : n === "siteAreaHa" ? "ha" : n === "lat" || n === "lng" ? "float" : "" })] })} className="rounded-xs border border-dashed border-hair-2 px-1.5 py-0.5 font-mono text-[11px] text-ink-3 hover:border-ink-4 hover:text-ink">90 + {n}91 </button>92 ))}93 <Button size="xs" variant="ghost" onClick={openAdvanced}>94 Edit as JSON95 </Button>96 </div>97 </div>98 {duplicates.length > 0 && <ErrorNote error={`Duplicate field names: ${[...new Set(duplicates)].join(", ")}`} className="m-3" />}99 <ul className="divide-y divide-hair">100 {ex.fields.map((f) => (101 <FieldRow key={f.id} f={f} onChange={(p) => setField(f.id, p)} onRemove={() => setEx({ fields: ex.fields.filter((x) => x.id !== f.id) })} />102 ))}103 </ul>104 <div className="px-3 py-2">105 <Button size="sm" variant="secondary" onClick={() => setEx({ fields: [...ex.fields, newFieldRule()] })}>106 <Plus className="size-3.5" /> Add field107 </Button>108 </div>109 </div>110111 {advanced && (112 <div className="rounded-sm border border-hair bg-surface p-3">113 <div className="mb-2 flex items-center justify-between">114 <span className="text-[13px] font-semibold text-ink">Extractor as JSON (schema: ExtractorConfig)</span>115 <div className="flex gap-1.5">116 <Button size="xs" variant="ghost" onClick={() => setAdvanced(false)}>117 Cancel118 </Button>119 <Button size="xs" variant="primary" onClick={applyAdvanced}>120 Apply121 </Button>122 </div>123 </div>124 <Textarea rows={14} mono value={jsonText} onChange={(e) => setJsonText(e.target.value)} />125 <ErrorNote error={jsonError} className="mt-2" />126 </div>127 )}128 </div>129130 <div className="space-y-2 xl:sticky xl:top-3 xl:self-start">131 <div className="flex items-center justify-between">132 <span className="label-caps">YAML preview · extractors block</span>133 <Badge size="xs" tone="outline" mono>134 {Object.keys((config.fields as object) ?? {}).length} rules135 </Badge>136 </div>137 <Code text={yamlPreview} lang="yaml" maxHeight={520} />138 <p className="text-[11.5px] leading-snug text-ink-4">139 Rule types map to the schema: <span className="font-mono">selector / attr / all / regex / group / jsonPath / jsonld / meta / transform / default</span>. Transforms normalize units (mw, sqm, ha, money), dates, statuses, types and countries — never invent a value: leave a field unset rather than defaulting to 0.140 </p>141 </div>142 </div>143 );144}145146function FieldRow({ f, onChange, onRemove }: { f: FieldRule; onChange: (p: Partial<FieldRule>) => void; onRemove: () => void }) {147 const t = f.type;148 const hint = RULE_TYPES.find((r) => r.value === t)?.hint;149 return (150 <li className="grid gap-2 px-3 py-2.5 lg:grid-cols-[150px_150px_1fr_130px_auto] lg:items-start">151 <Field label="Field">152 <Input value={f.name} onChange={(e) => onChange({ name: e.target.value.trim() })} mono placeholder="name" className={cn(!f.name && "border-warn")} />153 </Field>154 <Field label="Rule type" hint={hint}>155 <Select value={t} onChange={(e) => onChange({ type: e.target.value as FieldRule["type"] })}>156 {RULE_TYPES.map((r) => (157 <option key={r.value} value={r.value}>158 {r.label}159 </option>160 ))}161 </Select>162 </Field>163 <div className="grid gap-2 sm:grid-cols-2">164 {(t === "selector" || t === "attr" || t === "regex") && (165 <Field label={t === "regex" ? "Selector (optional scope)" : "CSS selector"} className={t === "selector" && !f.regex ? "sm:col-span-2" : ""}>166 <Input value={f.selector} onChange={(e) => onChange({ selector: e.target.value })} mono placeholder={t === "regex" ? "body" : "h1, .facility-title"} className={cn(t !== "regex" && !f.selector && "border-warn")} />167 </Field>168 )}169 {t === "attr" && (170 <Field label="Attribute">171 <Input value={f.attr} onChange={(e) => onChange({ attr: e.target.value })} mono placeholder="href / content / data-lat" className={cn(!f.attr && "border-warn")} />172 </Field>173 )}174 {t === "jsonld" && (175 <>176 <Field label="JSON-LD @type">177 <Input value={f.jsonld} onChange={(e) => onChange({ jsonld: e.target.value })} mono placeholder="Place, LocalBusiness, Organization" />178 </Field>179 <Field label="JSON path">180 <Input value={f.jsonPath} onChange={(e) => onChange({ jsonPath: e.target.value })} mono placeholder="$.address.addressLocality" />181 </Field>182 </>183 )}184 {t === "meta" && (185 <Field label="Meta name / property" className="sm:col-span-2">186 <Input value={f.meta} onChange={(e) => onChange({ meta: e.target.value })} mono placeholder="og:title, description, geo.position" className={cn(!f.meta && "border-warn")} />187 </Field>188 )}189 {t === "jsonPath" && (190 <Field label="JSON path over embedded JSON" className="sm:col-span-2">191 <Input value={f.jsonPath} onChange={(e) => onChange({ jsonPath: e.target.value })} mono placeholder="$.props.pageProps.node.field_power_mw" className={cn(!f.jsonPath && "border-warn")} />192 </Field>193 )}194 {(t === "regex" || (t === "selector" && f.regex !== undefined)) && (195 <>196 <Field label={t === "selector" ? "Regex on text (optional)" : "Regex"}>197 <Input value={f.regex} onChange={(e) => onChange({ regex: e.target.value })} mono placeholder="(\d+(?:\.\d+)?)\s*MW" className={cn(t === "regex" && !f.regex && "border-warn")} />198 </Field>199 {(f.regex || t === "regex") && (200 <div className="grid grid-cols-2 gap-2">201 <Field label="Flags">202 <Input value={f.regexFlags} onChange={(e) => onChange({ regexFlags: e.target.value })} mono placeholder="i" />203 </Field>204 <Field label="Group">205 <Input value={f.group} onChange={(e) => onChange({ group: e.target.value })} mono placeholder="1" inputMode="numeric" />206 </Field>207 </div>208 )}209 </>210 )}211 {t === "constant" && (212 <Field label="Value" className="sm:col-span-2">213 <Input value={f.default} onChange={(e) => onChange({ default: e.target.value })} mono placeholder="colocation" className={cn(!f.default && "border-warn")} />214 </Field>215 )}216 {(t === "selector" || t === "attr" || t === "regex") && <Checkbox label="all matches (array)" checked={f.all} onChange={(e) => onChange({ all: e.target.checked })} className="sm:col-span-2 -mt-1" />}217 </div>218 <div className="grid gap-2">219 <Field label="Transform">220 <Select value={f.transform} onChange={(e) => onChange({ transform: e.target.value })}>221 {TRANSFORMS.map((x) => (222 <option key={x} value={x}>223 {x || "none"}224 </option>225 ))}226 </Select>227 </Field>228 {t !== "constant" && (229 <Field label="Default">230 <Input value={f.default} onChange={(e) => onChange({ default: e.target.value })} mono placeholder="—" />231 </Field>232 )}233 </div>234 <div className="flex justify-end lg:pt-5">235 <Button size="sm" variant="ghost" onClick={onRemove} aria-label="Remove field">236 <Trash2 className="size-3.5 text-ink-3" />237 </Button>238 </div>239 </li>240 );241}242