SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
5 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
13.2 KB · 242 lines tsx
Raw Blame History
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