@directory-builder/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -0
  3. package/bin/cli.js +38 -0
  4. package/example/README.md +64 -0
  5. package/example/config/federation.ttl +136 -0
  6. package/example/config/match-knowledge.ttl +8 -0
  7. package/example/sources/cityopen/clean.sparql +17 -0
  8. package/example/sources/cityopen/fetch.js +14 -0
  9. package/example/sources/cityopen/static/libraries.json +32 -0
  10. package/example/sources/civichub/clean.sparql +34 -0
  11. package/example/sources/civichub/fetch.js +14 -0
  12. package/example/sources/civichub/static/libraries.json +38 -0
  13. package/package.json +38 -0
  14. package/src/federate.js +571 -0
  15. package/src/index.js +6 -0
  16. package/src/ingest.js +158 -0
  17. package/src/lift/html.sparql +12 -0
  18. package/src/lift/json.sparql +12 -0
  19. package/src/pipeline.js +16 -0
  20. package/src/utils.js +152 -0
  21. package/src/webapp.js +41 -0
  22. package/webapp/index.html +11 -0
  23. package/webapp/src/About.jsx +24 -0
  24. package/webapp/src/App.jsx +96 -0
  25. package/webapp/src/Card.jsx +32 -0
  26. package/webapp/src/ColumnGraph.jsx +290 -0
  27. package/webapp/src/Directory.jsx +15 -0
  28. package/webapp/src/Download.jsx +174 -0
  29. package/webapp/src/MapGraph.jsx +244 -0
  30. package/webapp/src/MatchGraph.jsx +137 -0
  31. package/webapp/src/MergeTables.jsx +61 -0
  32. package/webapp/src/OrgCard.jsx +126 -0
  33. package/webapp/src/Pipeline.jsx +41 -0
  34. package/webapp/src/Query.jsx +165 -0
  35. package/webapp/src/Sources.jsx +52 -0
  36. package/webapp/src/instanceData.js +35 -0
  37. package/webapp/src/loadMap.js +276 -0
  38. package/webapp/src/loadMatch.js +228 -0
  39. package/webapp/src/loadMerge.js +93 -0
  40. package/webapp/src/loadPipeline.js +130 -0
  41. package/webapp/src/loadSources.js +102 -0
  42. package/webapp/src/main.jsx +9 -0
  43. package/webapp/src/mergeOrgs.js +15 -0
  44. package/webapp/src/sourceMeta.js +81 -0
  45. package/webapp/src/styles.css +23 -0
  46. package/webapp/vite.config.js +14 -0
  47. package/webapp/vite.js +28 -0
package/src/ingest.js ADDED
@@ -0,0 +1,158 @@
1
+ import { sparqlSelect, storeFromTurtles } from "@foerderfunke/sem-ops-utils"
2
+ import { CDP, localName, objectsOf, parseTtl, PATHS, sourceName, stepJournal } from "./utils.js"
3
+ import { execSync, spawnSync } from "child_process"
4
+ import path from "path"
5
+ import fs from "fs"
6
+
7
+ const SPARQL_ANYTHING_VERSION = "v1.1.0"
8
+
9
+ const run = (cmd, args) => {
10
+ const r = spawnSync(cmd, args, { stdio: "inherit" })
11
+ if (r.status !== 0) throw new Error(`Exit ${r.status}: ${cmd} ${args.join(" ")}`)
12
+ }
13
+
14
+ // The generic lift queries ship with the engine — they resolve against this
15
+ // package, not the instance root like everything else in PATHS.
16
+ const liftQueryFor = (formatIri) =>
17
+ path.join(import.meta.dirname, "lift", `${localName(formatIri).toLowerCase()}.sparql`)
18
+
19
+ // Ingest engine: fetch + lift per source declared in the instance's
20
+ // federation.ttl. `root` is the instance directory all PATHS resolve against.
21
+ export async function ingest(root = process.cwd()) {
22
+ const abs = (p) => path.join(root, p)
23
+ const federationTtl = fs.readFileSync(abs(PATHS.federation), "utf8")
24
+ const defStore = storeFromTurtles([federationTtl])
25
+
26
+ // ---- Read the sources ------------------------------------------------
27
+ // The step graph (fetch → lift per source) is the engine's own shape;
28
+ // config declares only the sources and their facts. Lift params are SPARQL
29
+ // Anything variables declared per source. Sources run in :hasSource
30
+ // declaration order.
31
+
32
+ const facts = new Map()
33
+ for (const r of await sparqlSelect(`
34
+ PREFIX : <${CDP}>
35
+ SELECT ?source ?fetchUrl ?format ?paramName ?paramValue WHERE {
36
+ :federation :hasSource ?source .
37
+ OPTIONAL { ?source :fetchUrl ?fetchUrl }
38
+ OPTIONAL { ?source :format ?format }
39
+ OPTIONAL { ?source :hasLiftParam [ :name ?paramName ; :value ?paramValue ] }
40
+ }`, [defStore])) {
41
+ if (!facts.has(r.source)) facts.set(r.source, { fetchUrl: r.fetchUrl, format: r.format, params: [] })
42
+ if (r.paramName) facts.get(r.source).params.push([r.paramName, r.paramValue])
43
+ }
44
+ const sources = new Map(objectsOf(parseTtl(federationTtl), `${CDP}hasSource`).map((iri) => [iri, facts.get(iri)]))
45
+ for (const [iri, s] of sources) {
46
+ if (!s.format) throw new Error(`${iri} declares no :format (needed to pick the lift query)`)
47
+ }
48
+
49
+ // ---- Ensure sparql-anything.jar ----------------------------------------
50
+
51
+ const JAR = abs("tools/sparql-anything.jar")
52
+ const VERSION_FILE = abs("tools/sparql-anything.version")
53
+ const haveCurrentJar = fs.existsSync(JAR) && fs.existsSync(VERSION_FILE)
54
+ && fs.readFileSync(VERSION_FILE, "utf8").trim() === SPARQL_ANYTHING_VERSION
55
+
56
+ if (!haveCurrentJar) {
57
+ const url = `https://github.com/SPARQL-Anything/sparql.anything/releases/download/${SPARQL_ANYTHING_VERSION}/sparql-anything-${SPARQL_ANYTHING_VERSION}.jar`
58
+ console.log(`Downloading sparql-anything ${SPARQL_ANYTHING_VERSION}...`)
59
+ fs.mkdirSync(path.dirname(JAR), { recursive: true })
60
+ const response = await fetch(url)
61
+ if (!response.ok) throw new Error(`Failed to fetch ${url}: ${response.status}`)
62
+ fs.writeFileSync(JAR, Buffer.from(await response.arrayBuffer()))
63
+ fs.writeFileSync(VERSION_FILE, SPARQL_ANYTHING_VERSION)
64
+ console.log(`Saved to ${JAR}`)
65
+ }
66
+
67
+ // ---- Run steps ----------------------------------------------------------
68
+
69
+ // All :hasRunParam values grouped by name, handed to every fetcher as one
70
+ // JSON argument — each fetcher picks the parameters it needs.
71
+ const runParams = {}
72
+ for (const r of await sparqlSelect(`
73
+ PREFIX : <${CDP}>
74
+ SELECT ?name ?value WHERE { :federation :hasRunParam [ :name ?name ; :value ?value ] } ORDER BY ?name ?value`, [defStore])) {
75
+ (runParams[r.name] ??= []).push(r.value)
76
+ }
77
+ const paramsJson = JSON.stringify(runParams)
78
+
79
+ const runStart = new Date()
80
+ const harvests = []
81
+ const journal = stepJournal()
82
+ const fetchStepOf = new Map()
83
+
84
+ for (const [iri, s] of sources) {
85
+ const name = sourceName(iri)
86
+ fetchStepOf.set(iri, await journal.step("fetch", { source: iri }, () => {
87
+ const outDir = PATHS.raw(name)
88
+ // Live sources pass their :fetchUrl; static-file sources pass the
89
+ // absolute static dir instead. The script gets whichever applies.
90
+ const origin = s.fetchUrl ?? abs(PATHS.staticDir(name))
91
+ console.log(`fetch ${s.fetchUrl ?? PATHS.staticDir(name)} (params ${paramsJson}) → ${outDir}`)
92
+ fs.mkdirSync(abs(outDir), { recursive: true })
93
+ run("node", [abs(PATHS.fetchScript(name)), abs(outDir), origin, paramsJson])
94
+ const harvest = { source: iri, time: new Date().toISOString() }
95
+ // Static sources have no live harvest — record the files' git commit
96
+ // time instead (the freshness the Sources page shows for them).
97
+ if (!s.fetchUrl) try {
98
+ const iso = execSync(`git log -1 --format=%cI -- "${PATHS.staticDir(name)}"`, { cwd: root, encoding: "utf8" }).trim()
99
+ if (iso) harvest.staticCommittedAt = iso
100
+ } catch { /* not committed yet / no git → omit */ }
101
+ harvests.push(harvest)
102
+ }))
103
+ }
104
+
105
+ for (const [iri, s] of sources) {
106
+ const name = sourceName(iri)
107
+ await journal.step("lift", { source: iri, after: [fetchStepOf.get(iri)] }, () => {
108
+ // TODO: directory mode spawns one JVM per file (~1s startup each).
109
+ // Fine at small N; revisit if a source crosses ~50 items. SPARQL Anything
110
+ // accepts VALUES ?_location { … } in the lift query, which would let one
111
+ // invocation handle the whole batch.
112
+ const liftQuery = liftQueryFor(s.format)
113
+ const liftOne = (location, outPath) => {
114
+ const args = ["-jar", JAR, "-q", liftQuery,
115
+ "-v", `location=${location}`,
116
+ "-f", "TTL", "-o", outPath]
117
+ for (const [pName, value] of s.params) args.push("-v", `${pName}=${value}`)
118
+ run("java", args)
119
+ }
120
+ const inAbs = abs(PATHS.raw(name))
121
+ const outAbs = abs(PATHS.lifted(name))
122
+ const files = fs.readdirSync(inAbs).filter(f => !f.startsWith(".")).sort()
123
+ fs.mkdirSync(outAbs, { recursive: true })
124
+ console.log(`lift ${PATHS.raw(name)} (${files.length} files) → ${PATHS.lifted(name)}`)
125
+ for (const f of files) {
126
+ const stem = path.basename(f, path.extname(f))
127
+ liftOne(path.join(inAbs, f), path.join(outAbs, `${stem}.ttl`))
128
+ }
129
+ })
130
+ }
131
+
132
+ const dt = (s) => `"${s}"^^xsd:dateTime`
133
+ const runId = "run" + runStart.toISOString().replace(/\D/g, "").slice(0, 14)
134
+ const harvestPart = harvests.length
135
+ ? ` ;\n :harvested\n` + harvests.map((h) => {
136
+ const local = h.source.split("#").pop()
137
+ const committed = h.staticCommittedAt ? ` ; :staticCommittedAt ${dt(h.staticCommittedAt)}` : ""
138
+ return ` [ :ofSource :${local} ; prov:atTime ${dt(h.time)}${committed} ]`
139
+ }).join(" ,\n")
140
+ : ""
141
+
142
+ const block = `
143
+ ${journal.toTurtle()}
144
+
145
+ :${runId} a :IngestRun ;
146
+ prov:startedAtTime ${dt(runStart.toISOString())} ;
147
+ prov:endedAtTime ${dt(new Date().toISOString())}${harvestPart} .
148
+ `
149
+
150
+ const prefixes = `@prefix : <${CDP}> .
151
+ @prefix p-plan: <http://purl.org/net/p-plan#> .
152
+ @prefix prov: <http://www.w3.org/ns/prov#> .
153
+ @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
154
+ `
155
+ fs.mkdirSync(path.dirname(abs(PATHS.ingestLog)), { recursive: true })
156
+ fs.writeFileSync(abs(PATHS.ingestLog), prefixes + block)
157
+ console.log(`log: wrote steps + IngestRun → ${PATHS.ingestLog}`)
158
+ }
@@ -0,0 +1,12 @@
1
+ PREFIX xyz: <http://sparql.xyz/facade-x/data/>
2
+ PREFIX fx: <http://sparql.xyz/facade-x/ns/>
3
+ PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
4
+ CONSTRUCT {
5
+ ?s ?p ?o
6
+ } WHERE {
7
+ SERVICE <x-sparql-anything:> {
8
+ fx:properties fx:location ?_location ;
9
+ fx:html.selector ?_selector .
10
+ ?s ?p ?o .
11
+ }
12
+ }
@@ -0,0 +1,12 @@
1
+ PREFIX xyz: <http://sparql.xyz/facade-x/data/>
2
+ PREFIX fx: <http://sparql.xyz/facade-x/ns/>
3
+ PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
4
+ CONSTRUCT {
5
+ ?s ?p ?o
6
+ } WHERE {
7
+ SERVICE <x-sparql-anything:> {
8
+ fx:properties fx:location ?_location ;
9
+ fx:json.include-null-values true .
10
+ ?s ?p ?o .
11
+ }
12
+ }
@@ -0,0 +1,16 @@
1
+ import { ingest } from "./ingest.js"
2
+ import { federate } from "./federate.js"
3
+
4
+ // Programmatic entry: hold the instance root once, run the engines against it.
5
+ // The CLI (bin/cli.js) is this same class with defaults — root = cwd.
6
+ export class Pipeline {
7
+ constructor({ root = process.cwd() } = {}) {
8
+ this.root = root
9
+ }
10
+ ingest() { return ingest(this.root) }
11
+ federate() { return federate(this.root) }
12
+ async run() {
13
+ await this.ingest()
14
+ await this.federate()
15
+ }
16
+ }
package/src/utils.js ADDED
@@ -0,0 +1,152 @@
1
+ // Shared helpers used by both the Node engines and instance webapps (browser),
2
+ // exported as "@directory-builder/core/utils". Keep this file browser-safe —
3
+ // no `fs`, no Node-only APIs. File-IO helpers belong in their consumer.
4
+
5
+ import { Parser } from "n3"
6
+
7
+ const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"
8
+
9
+ // The engine's vocabulary namespace (config, journals, cdp: in artifacts).
10
+ export const CDP = "https://civic-data.de/pipeline#"
11
+
12
+ export const localName = (iri) => iri.replace(/^.*[#/]/, "")
13
+
14
+ // ---- Path conventions ----------------------------------------------------
15
+ // All file paths follow from the source name (the :Source IRI's local name
16
+ // minus its "Source" suffix): a source's artefacts live in sources/<name>/,
17
+ // its data flows through data/ingest/ and data/pipeline/ under the same name.
18
+
19
+ export const sourceName = (sourceIri) => localName(sourceIri).replace(/Source$/, "")
20
+
21
+ export const sourceGraph = (name) => `urn:source:${name}`
22
+
23
+ // Local name of the step IRIs the engines mint when journaling their run
24
+ // (ingest-log.ttl / federate-log.ttl): ("fetch", "caritas") → "fetchCaritas".
25
+ // Shared so the journals' cross-file p-plan:isPrecededBy references line up.
26
+ export const stepIri = (type, name) => type + name[0].toUpperCase() + name.slice(1)
27
+
28
+ // Journal of executed pipeline steps, recorded as a side effect of running
29
+ // them: step() executes fn and only then keeps the entry, so a step appears
30
+ // in the journal iff it ran, and the isPrecededBy edges are the IRIs actual
31
+ // execution threaded through (cross-engine edges reference the other
32
+ // journal's IRIs via stepIri). Per-source steps mint stepIri(type, source
33
+ // name), singletons "<type>Step". toTurtle() emits the p-plan triples; the
34
+ // engine owns its file's prefix header.
35
+ export function stepJournal() {
36
+ const steps = []
37
+ return {
38
+ async step(type, { source, after = [] } = {}, fn) {
39
+ const iri = source ? stepIri(type, sourceName(source)) : `${type}Step`
40
+ await fn()
41
+ steps.push({ iri, type, source, after })
42
+ return iri
43
+ },
44
+ toTurtle: () => steps.map((s) =>
45
+ `:${s.iri} a :${s.type[0].toUpperCase()}${s.type.slice(1)}, p-plan:Step` +
46
+ (s.source ? ` ; :fromSource :${localName(s.source)}` : "") +
47
+ (s.after.length ? ` ; p-plan:isPrecededBy ${s.after.map((a) => `:${a}`).join(", ")}` : "") +
48
+ " .").join("\n"),
49
+ }
50
+ }
51
+
52
+ // Engine invariant, mirrored for display: the lift step always emits Turtle
53
+ // (ingest.js invokes SPARQL Anything with -f TTL).
54
+ export const LIFTED_FORMAT = "http://publications.europa.eu/resource/authority/file-type/RDF_TURTLE"
55
+
56
+ export const PATHS = {
57
+ federation: "config/federation.ttl",
58
+ matchKnowledge: "config/match-knowledge.ttl",
59
+ about: "webapp/content/about.md",
60
+ fetchScript: (name) => `sources/${name}/fetch.js`,
61
+ exporter: (name) => `webapp/exporters/${name}.js`,
62
+ staticDir: (name) => `sources/${name}/static/`,
63
+ cleanQuery: (name) => `sources/${name}/clean.sparql`,
64
+ transform: (name, t) => `sources/${name}/transform-${t}.sparql`,
65
+ raw: (name) => `data/ingest/raw/${name}/`,
66
+ lifted: (name) => `data/ingest/lifted/${name}/`,
67
+ cleaned: (name) => `data/pipeline/cleaned/${name}.ttl`,
68
+ ingestLog: "data/ingest/ingest-log.ttl",
69
+ federateLog: "data/pipeline/federate-log.ttl",
70
+ mappingQueries: "data/pipeline/direct-mapping-queries/",
71
+ mapped: "data/pipeline/mapped.ttl",
72
+ matches: "data/pipeline/matches.ttl",
73
+ merged: "data/pipeline/merged.ttl",
74
+ provenance: "data/pipeline/provenance.ttl",
75
+ final: "data/pipeline/final.ttl",
76
+ }
77
+
78
+ // Format family of a file-type IRI (EU file-type authority): the code before
79
+ // any "_", used as a short display label — .../RDF_TURTLE -> "RDF", .../JSON -> "JSON".
80
+ export const formatFamily = (iri) => localName(iri).split("_")[0]
81
+
82
+ export const parseTtl = (turtle) => new Parser().parse(turtle)
83
+
84
+ // {prefix: namespace} declared in a Turtle document's @prefix/PREFIX lines.
85
+ // Textual on purpose: n3's Parser only reports prefixes via callbacks, which
86
+ // turn parsing asynchronous — and this must stay sync (top-level module init).
87
+ export const prefixesOf = (turtle) =>
88
+ Object.fromEntries([...turtle.matchAll(/^\s*@?prefix\s+([\w-]*):\s*<([^>]*)>/gim)].map(([, p, ns]) => [p, ns]))
89
+
90
+ // Turtle with RDF-star triple terms in subject position (the engine's
91
+ // provenance annotations) — plain Turtle parsing disallows those, N3 mode
92
+ // accepts them.
93
+ export const parseTtlStar = (turtle) => new Parser({ format: "text/n3" }).parse(turtle)
94
+
95
+ // {prefix: namespace} → "PREFIX p1: <ns1>\nPREFIX p2: <ns2>"
96
+ export const buildPrefixBlock = (prefixMap) =>
97
+ Object.entries(prefixMap).map(([p, ns]) => `PREFIX ${p}: <${ns}>`).join("\n")
98
+
99
+ // Returns the IRI shortened against the supplied {prefix: namespace} map,
100
+ // or the original IRI verbatim if no prefix matches.
101
+ export const shrink = (iri, prefixMap) => {
102
+ for (const [p, ns] of Object.entries(prefixMap)) {
103
+ if (iri.startsWith(ns)) return `${p}:${iri.slice(ns.length)}`
104
+ }
105
+ return iri
106
+ }
107
+
108
+ // Objects of every `predIri` triple, deduped, in encounter order. RDF has no
109
+ // statement order, but Turtle parse order preserves it — so the federation's
110
+ // :hasSource declaration order is meaningful and governs source ordering
111
+ // everywhere (engine runs, journals, webapp lanes/cards).
112
+ export const objectsOf = (quads, predIri) =>
113
+ [...new Set(quads.filter((q) => q.predicate.value === predIri).map((q) => q.object.value))]
114
+
115
+ // Set of subjects typed `rdf:type typeIri`. Iteration order = encounter order.
116
+ export function subjectsOfType(quads, typeIri) {
117
+ const out = new Set()
118
+ for (const q of quads) {
119
+ if (q.predicate.value === RDF_TYPE && q.object.value === typeIri) out.add(q.subject.value)
120
+ }
121
+ return out
122
+ }
123
+
124
+ // Map<subjectIri, Set<typeIri>> for every typed subject in quads.
125
+ export function typesOf(quads) {
126
+ const out = new Map()
127
+ for (const q of quads) {
128
+ if (q.predicate.value !== RDF_TYPE) continue
129
+ let set = out.get(q.subject.value)
130
+ if (!set) { set = new Set(); out.set(q.subject.value, set) }
131
+ set.add(q.object.value)
132
+ }
133
+ return out
134
+ }
135
+
136
+ // Map<subjectIri, Map<predicateIri, valueString[]>>. Values come from
137
+ // q.object.value, so literals and IRIs both render as strings. Insertion order
138
+ // of the outer Map = encounter order of subjects in quads.
139
+ export function groupBySubject(quads, { literalsOnly = false } = {}) {
140
+ const out = new Map()
141
+ for (const q of quads) {
142
+ if (literalsOnly && q.object.termType !== "Literal") continue
143
+ const s = q.subject.value
144
+ let row = out.get(s)
145
+ if (!row) { row = new Map(); out.set(s, row) }
146
+ const p = q.predicate.value
147
+ let arr = row.get(p)
148
+ if (!arr) { arr = []; row.set(p, arr) }
149
+ arr.push(q.object.value)
150
+ }
151
+ return out
152
+ }
package/src/webapp.js ADDED
@@ -0,0 +1,41 @@
1
+ import path from "path"
2
+ import fs from "fs"
3
+
4
+ // The webapp ships with this package as source; these run it through vite's
5
+ // JS API for an instance directory — dev server or dist build. The vite
6
+ // project root is the package's webapp/; the instance root reaches the config
7
+ // via the INSTANCE env var (see webapp/vite.config.js). vite is imported
8
+ // lazily so engine-only CLI runs never load it.
9
+
10
+ const WEBAPP = path.join(import.meta.dirname, "../webapp")
11
+ const CONFIG = path.join(WEBAPP, "vite.config.js")
12
+
13
+ export async function webappDev(root = process.cwd()) {
14
+ const { createServer } = await import("vite")
15
+ process.env.INSTANCE = root
16
+ const server = await createServer({ configFile: CONFIG, root: WEBAPP })
17
+ await server.listen()
18
+ server.printUrls()
19
+ }
20
+
21
+ export async function webappBuild(root = process.cwd(), { base } = {}) {
22
+ const { build } = await import("vite")
23
+ process.env.INSTANCE = root
24
+ const outDir = path.join(root, "webapp/dist")
25
+ await build({
26
+ configFile: CONFIG,
27
+ root: WEBAPP,
28
+ ...(base ? { base } : {}),
29
+ build: { outDir, emptyOutDir: true },
30
+ })
31
+ // The bundle fetches the instance's config, data and webapp/{content,
32
+ // exporters} at runtime — they are part of the deployable, so stage them
33
+ // next to it (URL paths mirror the repo paths).
34
+ const staged = ["config", "data", "webapp/content", "webapp/exporters"].filter((dir) => {
35
+ const from = path.join(root, dir)
36
+ if (!fs.existsSync(from)) return false
37
+ fs.cpSync(from, path.join(outDir, dir), { recursive: true })
38
+ return true
39
+ })
40
+ console.log(`staged ${staged.join(", ")} → webapp/dist/`)
41
+ }
@@ -0,0 +1,11 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>directory-builder</title>
6
+ </head>
7
+ <body>
8
+ <div id="root"></div>
9
+ <script type="module" src="/src/main.jsx"></script>
10
+ </body>
11
+ </html>
@@ -0,0 +1,24 @@
1
+ // About page: instances own the content by providing webapp/content/about.md
2
+ // (fetched at runtime like config and data); without one, a generic default
3
+ // renders. Markdown, single newlines = line breaks.
4
+
5
+ import { aboutMd } from "./instanceData.js"
6
+ import { marked } from "marked"
7
+ import React from "react"
8
+
9
+ const DEFAULT = `## Federated directory
10
+
11
+ Builds a federated directory by mapping heterogeneous source schemas into a unified target schema.
12
+ The directory can be queried or downloaded.
13
+ This site serves both its users and those interested in the federation process itself.
14
+ Toggle "Show federation process" in the top bar to inspect the steps.
15
+
16
+ *The data shown here is example data: two fictional library directories exercising the pipeline and this webapp.
17
+ A real use case replaces it with its own sources and this text with its own about page.*`
18
+
19
+ export default function About() {
20
+ return (
21
+ <div className="page" style={{ maxWidth: "100ch", lineHeight: 1.5 }}
22
+ dangerouslySetInnerHTML={{ __html: marked.parse(aboutMd || DEFAULT, { breaks: true }) }} />
23
+ )
24
+ }
@@ -0,0 +1,96 @@
1
+ import { HashRouter, Routes, Route, NavLink } from "react-router-dom"
2
+ import "./styles.css"
3
+ import { repositoryUrl } from "./instanceData.js"
4
+ import About from "./About.jsx"
5
+ import React, { lazy, Suspense, useState } from "react"
6
+
7
+ // Lazy-load route views so their heavy deps load only when the route is
8
+ // visited: comunica + yasgui (Query), comunica + jsonld (Download), xyflow
9
+ // (Map/Match). About stays eager as the landing route.
10
+ const Directory = lazy(() => import("./Directory.jsx"))
11
+ const Download = lazy(() => import("./Download.jsx"))
12
+ const Pipeline = lazy(() => import("./Pipeline.jsx"))
13
+ const MapGraph = lazy(() => import("./MapGraph.jsx"))
14
+ const MatchGraph = lazy(() => import("./MatchGraph.jsx"))
15
+ const MergeTables = lazy(() => import("./MergeTables.jsx"))
16
+ const Query = lazy(() => import("./Query.jsx"))
17
+ const Sources = lazy(() => import("./Sources.jsx"))
18
+
19
+ const STORAGE_KEY = "showFederation"
20
+
21
+ const initialShowFed = () => {
22
+ try { return localStorage.getItem(STORAGE_KEY) === "true" } catch { return false }
23
+ }
24
+
25
+ function Nav() {
26
+ const [showFed, setShowFed] = useState(initialShowFed)
27
+ const update = (v) => {
28
+ setShowFed(v)
29
+ try { localStorage.setItem(STORAGE_KEY, String(v)) } catch {}
30
+ }
31
+ return (
32
+ <nav>
33
+ <div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
34
+ <NavLink to="/" end>About</NavLink>
35
+ {showFed && (
36
+ <div style={{ display: "flex", alignItems: "center", gap: "0.75rem", border: "1px solid #aaa", borderRadius: 4, padding: "0.3rem 0.6rem" }}>
37
+ <NavLink to="/sources">Sources</NavLink>
38
+ <NavLink to="/pipeline">Pipeline</NavLink>
39
+ <NavLink to="/map">Map</NavLink>
40
+ <NavLink to="/match">Match</NavLink>
41
+ <NavLink to="/merge">Merge</NavLink>
42
+ </div>
43
+ )}
44
+ <NavLink to="/directory">Directory</NavLink>
45
+ <NavLink to="/query">Query</NavLink>
46
+ <NavLink to="/download">Download</NavLink>
47
+ <NavLink to="/apis">APIs</NavLink>
48
+ </div>
49
+ <div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
50
+ <label style={{ display: "inline-flex", alignItems: "center", gap: "0.3rem", fontSize: 13, color: "#666" }}>
51
+ <input type="checkbox" checked={showFed} onChange={(e) => update(e.target.checked)} />
52
+ Show federation process
53
+ </label>
54
+ {repositoryUrl && <a href={repositoryUrl} target="_blank" rel="noreferrer">GitHub</a>}
55
+ </div>
56
+ </nav>
57
+ )
58
+ }
59
+
60
+ function Apis() {
61
+ return (
62
+ <div className="page">
63
+ <p><strong>TODO</strong>:</p>
64
+ <ul>
65
+ <li>OpenAPI / Swagger</li>
66
+ <li>SPARQL endpoint</li>
67
+ </ul>
68
+ </div>
69
+ )
70
+ }
71
+
72
+ export default function App() {
73
+ return (
74
+ <HashRouter>
75
+ <div style={{ display: "flex", flexDirection: "column", height: "100vh" }}>
76
+ <Nav />
77
+ <main>
78
+ <Suspense fallback={<div className="page">Loading…</div>}>
79
+ <Routes>
80
+ <Route path="/" element={<About />} />
81
+ <Route path="/pipeline" element={<Pipeline />} />
82
+ <Route path="/sources" element={<Sources />} />
83
+ <Route path="/map" element={<MapGraph />} />
84
+ <Route path="/match" element={<MatchGraph />} />
85
+ <Route path="/merge" element={<MergeTables />} />
86
+ <Route path="/directory" element={<Directory />} />
87
+ <Route path="/query" element={<Query />} />
88
+ <Route path="/download" element={<Download />} />
89
+ <Route path="/apis" element={<Apis />} />
90
+ </Routes>
91
+ </Suspense>
92
+ </main>
93
+ </div>
94
+ </HashRouter>
95
+ )
96
+ }
@@ -0,0 +1,32 @@
1
+ // Presentational building blocks: <Card> (titled box) and <KeyValueTable>.
2
+ // Reads: props (title, children, rows)
3
+ // Does: renders DOM; used by OrgCard and Sources
4
+
5
+ import React from "react"
6
+
7
+ export default function Card({ title, tag, children }) {
8
+ return (
9
+ <div className="org-card">
10
+ <div className="org-card-header">
11
+ <code>{title}</code>
12
+ {tag && <span style={{ marginLeft: "0.6rem", fontSize: 11, color: "#888", fontFamily: "monospace" }}>{tag}</span>}
13
+ </div>
14
+ {children}
15
+ </div>
16
+ )
17
+ }
18
+
19
+ export function KeyValueTable({ rows }) {
20
+ return (
21
+ <table>
22
+ <tbody>
23
+ {rows.map((r, i) => (
24
+ <tr key={r.key ?? i}>
25
+ <td>{r.label}</td>
26
+ <td>{r.value}</td>
27
+ </tr>
28
+ ))}
29
+ </tbody>
30
+ </table>
31
+ )
32
+ }