diff --git a/client/package-lock.json b/client/package-lock.json index 0ffad669..c5f1fd32 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -28,6 +28,7 @@ "express": "^4.21.1", "html-react-parser": "^1.4.4", "immutable": "^4.0.0-rc.12", + "jsonc-parser": "^3.3.1", "leaflet": "^1.7.1", "leaflet-draw": "^1.0.4", "leaflet-fullscreen": "^1.0.2", @@ -45,7 +46,6 @@ "process": "^0.11.10", "prop-types": "^15.8.1", "protomaps-leaflet": "^5.1.0", - "protomaps-themes-base": "^4.5.0", "qs": "^6.14.0", "react": "^18.0.0", "react-csv": "^2.0.3", @@ -11535,6 +11535,12 @@ "node": ">=6" } }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -13369,13 +13375,6 @@ "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", "license": "ISC" }, - "node_modules/protomaps-themes-base": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/protomaps-themes-base/-/protomaps-themes-base-4.5.0.tgz", - "integrity": "sha512-z5A6oXAXNi/CHEIbCfzZ/6uVDgnv3pO4C3LFvomxjUFXwyqWZ7HJdfPoNJSIlsvaQubqsaxCbamEzqBr/W7vkg==", - "deprecated": "This package has been migrated to @protomaps/basemaps with a new major version. See the CHANGELOG at https://github.com/protomaps/basemaps/blob/main/CHANGELOG.md for how to migrate.", - "license": "BSD-3-Clause" - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", diff --git a/client/package.json b/client/package.json index a3618554..2711a593 100644 --- a/client/package.json +++ b/client/package.json @@ -36,6 +36,7 @@ "express": "^4.21.1", "html-react-parser": "^1.4.4", "immutable": "^4.0.0-rc.12", + "jsonc-parser": "^3.3.1", "leaflet": "^1.7.1", "leaflet-draw": "^1.0.4", "leaflet-fullscreen": "^1.0.2", diff --git a/client/src/components/main_layout/InstancePage.js b/client/src/components/main_layout/InstancePage.js index 98ae4ad8..02f91a8e 100644 --- a/client/src/components/main_layout/InstancePage.js +++ b/client/src/components/main_layout/InstancePage.js @@ -6,7 +6,7 @@ import Typography from '@mui/material/Typography' import CircularProgress from '@mui/material/CircularProgress' import PerspectiveTabs from 'components/main_layout/PerspectiveTabs' import ResultClassRoute from 'components/facet_results/ResultClassRoute' -import { getLocalIDFromAppLocation, createURIfromLocalID } from 'helpers/helpers' +import { getLocalIDFromAppLocation, createURIfromLocalID, createInstancePagePath } from 'helpers/helpers' import { Route, Redirect } from 'react-router-dom' import { has } from 'lodash' @@ -116,6 +116,7 @@ class InstancePage extends React.Component { const { classes, perspectiveState, perspectiveConfig, rootUrl, screenSize, layoutConfig } = this.props const { fetching } = perspectiveState const resultClass = perspectiveConfig.id + const instancePagePath = createInstancePagePath({ perspectiveConfig, localID: this.state.localID }) const defaultInstancePageTab = perspectiveConfig.defaultInstancePageTab ? perspectiveConfig.defaultInstancePageTab : 'table' @@ -146,11 +147,11 @@ class InstancePage extends React.Component { {hasTableData && <> } @@ -164,7 +165,7 @@ class InstancePage extends React.Component { return null } const { tabPath } = resultClassConfig - const path = `${rootUrl}/${resultClass}/page/${this.state.localID}/${tabPath}` + const path = `${rootUrl}${instancePagePath}/${tabPath}` return ( { {perspective.resultClasses[perspective.id].instanceConfig && - + { {perspectiveConfigsInfoOnlyPages.map(perspective => - + , with reduced to the path of baseURI: +// baseURI 'http://example.com/resource' + URITemplate '/corporation/' +// gives '/resource/corporation'. Only the path is used, so instance page URLs match the +// resource URI in production and still work on any other host in development. +const getInstancePageURIPrefix = ({ baseURI, URITemplate }) => { + const basePath = new URL(baseURI).pathname.replace(/\/+$/, '') + return URITemplate + .replaceAll('', basePath) + .replace(/\/?.*$/, '') +} + +const useURIAsInstancePageUrl = perspectiveConfig => + perspectiveConfig.instancePageUrlMode === 'uri' && + has(perspectiveConfig, 'baseURI') && has(perspectiveConfig, 'URITemplate') + +// Route pattern for a perspective's instance pages, e.g. '/resource/corporation/:id' +export const getInstancePagePathPattern = ({ perspectiveConfig }) => + useURIAsInstancePageUrl(perspectiveConfig) + ? `${getInstancePageURIPrefix(perspectiveConfig)}/:id` + : `/${perspectiveConfig.id}/page/:id` + +// Path of a single instance page, e.g. '/resource/corporation/12345' +export const createInstancePagePath = ({ perspectiveConfig, localID }) => + useURIAsInstancePageUrl(perspectiveConfig) + ? `${getInstancePageURIPrefix(perspectiveConfig)}/${localID}` + : `/${perspectiveConfig.id}/page/${localID}` + export const getSpacing = (theme, value) => Number(theme.spacing(value).slice(0, -2)) export const getScreenSize = () => { diff --git a/client/src/stores/configsStore.js b/client/src/stores/configsStore.js index 3a216b17..904a0020 100644 --- a/client/src/stores/configsStore.js +++ b/client/src/stores/configsStore.js @@ -1,4 +1,5 @@ import { create } from 'zustand' +import { parse } from 'jsonc-parser' import { configHelpers } from 'stores/helpers' const apiUrl = process.env.API_URL @@ -36,7 +37,7 @@ export const useConfigsStore = create((set, get) => ({ if (get().portalConfig !== null) { return get().portalConfig } else { - const portal = await fetch(`${CONFIGS_URL}/portalConfig.json`).then(res => res.json()) + const portal = await fetch(`${CONFIGS_URL}/portalConfig.json`).then(res => res.text()).then(text => parse(text)) set({ portalConfig: portal }) return portal } @@ -49,7 +50,7 @@ export const useConfigsStore = create((set, get) => ({ if (file in get().jsonConfigs) { return get().jsonConfigs[file] } else { - const jsonFile = await fetch(`${CONFIGS_URL}/${get().portalConfig.portalID}/${file}`).then(res => res.json()) + const jsonFile = await fetch(`${CONFIGS_URL}/${get().portalConfig.portalID}/${file}`).then(res => res.text()).then(text => parse(text)) set(state => ({ jsonConfigs: { ...state.jsonConfigs, [file]: jsonFile } })) diff --git a/docs/README.md b/docs/README.md index 632907c4..cd23ec17 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,18 @@ # SAMPO-UI docs -- [Introduction](pages/Introduction.md) +Sampo-ui is a data visualisation framework designed to easily configure a browsable front end for sparql endpoints. + +Sampo consists of a core app and config files. A sampo app runs by mounting the configs into the core app containers, +meaning when building a portal only config files need to be created. + +These docs are intended for developers using sampo-ui to build apps as well as for developers working on the sampo core. + +These documentations explain the current state of version 4. + +- [Portal Config](./pages/PortalConfig.md) +- [Perspectives Config](./pages/PerspectiveConfig.md) +- [Sparql Config](./pages/SparqlConfig.md) +- [Locales](./pages/Locales.md) - [Custom Components](pages/CustomComponents.md) - [Deployment](pages/Deployment.md) - [Changelog](CHANGELOG.md) \ No newline at end of file diff --git a/docs/pages/Introduction.md b/docs/pages/Introduction.md deleted file mode 100644 index 328b932c..00000000 --- a/docs/pages/Introduction.md +++ /dev/null @@ -1 +0,0 @@ -# Sampo-ui introduction diff --git a/docs/pages/Locales.md b/docs/pages/Locales.md new file mode 100644 index 00000000..7a403559 --- /dev/null +++ b/docs/pages/Locales.md @@ -0,0 +1,52 @@ +# Locales + +Locale config files are json config where the actual text content of the portal is written. A locale.json must be made +per language the portal uses. + +Each entry in this json file refers to some ID defined either in config or in sampo core. + +## General entries +The upper half of the example locale files in this repository (up until `perspectives`) contains some general locale config +that most portals will use. It is fine to copy this and edit the text to fit your portal or remove parts you do not need. + + +## Properties +In the `perspectives` object the text for facets and table columns gets defined. The most important thing to know here is that the same text gets used for column and facet labels and descriptions for the matching properties. + +## Text pages + +### Info dropdown +All pages defined in the `infoDropdown` in `portalConfig.json` must have their page content defined in locales. + +For example: +``` +"infoDropdown": [ + { + "id": "about", + "externalLink": false, + "translatedText": "aboutPage", + "internalLink": "/about" + } +] +``` +must in the root of locales have something like: +``` +"aboutPage": "htmlFile:pages/about_en.html" +``` +and then put the actual content of the page in said html file. The content can also be put directly in the locale json as a string, but since that gets very dirty for large pages it is not recommended. + + +### Dummy internal perspectives +The above part can only put these pages in the info dropdown. It is also possible to add them as perspectives, meaning +they appear as their own button in the topbar and can have a card on the front page. + +To define that make a new perspective json file under ``search_perspectives`` and structure it like this: +``` +{ + "id": "about", + "searchMode": "dummy-internal", + "internalLink": "/about", + "hideTopPerspectiveButton": false +} +``` +Then also add it in `portalConfig.json` as if it were a normal perspective. diff --git a/docs/pages/PerspectiveConfig.md b/docs/pages/PerspectiveConfig.md new file mode 100644 index 00000000..45e753c3 --- /dev/null +++ b/docs/pages/PerspectiveConfig.md @@ -0,0 +1,230 @@ +# Perspectives config + +Every perspective referenced in `portalConfig.json` needs a perspective json config file. Some example configs are presented here. + +## Search perspectives +Search perspectives are the perspectives for faceted search pages. + +### General structure + +``` +{ + "id": "corporations", + ... // more general config + "resultClasses": { + "corporations": { + "paginatedResultsConfig": { + "tabID": 0, + "component": "ResultTable", + ... // more resultclass config + }, + "instanceConfig": { + ... + "instancePageResultClasses": { + "instancePageTable": { + "tabID": 0, + "component": "InstancePageTable", + ... + } + } + } + }, + "export": { + "tabID": 1, + "component": "ExportCSV", + ... + }, + ... //more tabs + }, + "properties": [ + { + "id": "scobID", + ... // more property config + }, + { + "id": "uri", + ... + }, + ... // more properties + ], + "facets": { + "corporationName": { + ... // facet config + }, + ... // more facets + } +} +``` + +The id of the perspective is what you need to reference in `portalConfig.json`. + +In resultClasses you define all the different tabs you want in your perspective, as well as eventually an instanceConfig if you want an instance page with the same properties as your table. + +Under properties, you can define all the columns shown in your table. + +Under facets, you can define all the filters and sorting predicates of your columns. + +--- + +### General config properties +``` +"id": "corporations", +"endpoint": { + "url": "", + "useAuth": false, + "prefixesFile": "SparqlQueriesPrefixes.js", + "defaultSparql": true +}, +"generalQueries": { + "facetResultSetQuery": "facetResultSetQueryCustom" +}, +"sparqlQueriesFile": "SparqlQueriesScob.js", +"baseURI": "http://example.com/resource", +"URITemplate": "/corporation/", +"facetClass": "bhf:Corporation", +"frontPageImage": "main_page/events-452x262.jpg", +"searchMode": "faceted-search", +"defaultTab": "table", +"defaultInstancePageTab": "table", +``` + +#### Instance page URLs: `baseURI`, `URITemplate` and `instancePageUrlMode` + +Instance page URLs contain only a short `LOCAL_ID`, not the full resource URI. `baseURI` and +`URITemplate` are what turn that `LOCAL_ID` back into the URI that gets queried, so `URITemplate` +must be the exact inverse of however your SPARQL builds the link. `` must be the **last** +segment of `URITemplate`. + +`instancePageUrlMode` chooses the URL shape: + +| Value | Instance page URL for `http://example.com/resource/corporation/12345` | +|---|---| +| omitted / `"localID"` | `/en/corporations/page/12345` (default) | +| `"uri"` | `/en/resource/corporation/12345` | + +In `"uri"` mode the URL path is the resource URI's path, so in production — where the portal is +served from the same domain as your URIs — the URI itself is a working address: opening +`http://example.com/resource/corporation/12345` lands on the instance page (it redirects to add the +default locale, exactly like a locale-less link does today). Only the URI's *path* is ever used in +URLs, and the URI is rebuilt from the configured `baseURI` rather than from the browser's address, +so the same config also works in development on `localhost:8080/resource/corporation/12345`. + +`"uri"` mode requires both `baseURI` and `URITemplate`; if either is missing it falls back to the +default URL shape. Faceted search URLs are unaffected and keep the `/en/corporations/faceted-search/table` +form, so a portal using this mode has two URL styles side by side. + +Each mode needs a matching `dataProviderUrl` binding in your SPARQL — this is what builds the link, +and the framework uses the string verbatim. Default mode keeps only the last URI segment: + +```sparql +BIND(CONCAT("/corporations/page/", REPLACE(STR(?id), "^.*\\/(.+)", "$1")) AS ?prefLabel__dataProviderUrl) +``` + +`"uri"` mode drops `/page/` and the perspective, and emits the URI's path instead. The domain is +written out in full because it is a property of your data, identical in development and production: + +```sparql +BIND(IF(STRSTARTS(STR(?id), "http://example.com/resource/"), + STRAFTER(STR(?id), "http://example.com"), "") AS ?prefLabel__dataProviderUrl) +``` + +The `STRSTARTS` guard matters: `STRAFTER` returns an empty string when the URI does not start with +your domain, and a resource with an empty `dataProviderUrl` renders as plain text instead of a link. + +Limitations of `"uri"` mode: URIs with `#` fragments cannot be used (a `#` becomes a browser +fragment); the `baseURIs` prefix-map is not supported (use the singular `baseURI`); and because the +route is mounted at the site root, the first path segment of your URIs must not collide with a +locale code or a perspective id. + +--- + +### Table resultClass +The most basic resultclass used in virtually every perspective is table with also an associated instance page. + +#### ResultClass +``` +"resultClasses": { + "yourTable": { + "paginatedResultsConfig": { + "tabID": 0, + "component": "ResultTable", + "tabPath": "table", + "tabIcon": "CalendarViewDay", + "propertiesQueryBlock": "yourQueryBlock", + "pagesize": 20, + "sortBy": null, + "sortDirection": null + }, + "instanceConfig": { + "propertiesQueryBlock": "yourQueryBlock", + "instancePageResultClasses": { + "instancePageTable": { + "tabID": 0, + "component": "InstancePageTable", + "tabPath": "table", + "tabIcon": "CalendarViewDay" + } + } + } + }, + ... +}, +``` +See [sparql config](./SparqlConfig.md) for how to exactly config your sparql queries. + +#### Properties +``` +"properties": [ + { + "id": "ID", + "valueType": "object", + "makeLink": true, + "externalLink": false, + "sortValues": true, + "numberedList": false, + "minWidth": 30 + }, + { + "id": "uri", + "valueType": "object", + "makeLink": false, + "sortValues": true, + "onlyOnInstancePage": true + }, + { + "id": "name", + "valueType": "object", + "makeLink": true, + "externalLink": false, + "sortValues": true, + "sortBy": "startDate", + "numberedList": false, + "minWidth": 250 + }, + ... +], +``` +The properties list lists all columns that can be displayed in the table. See [sparql config](./SparqlConfig.md) for how exactly to query the data to be displayed in the table. `onlyOnInstancePage` can be set to only display the value on instance pages as to not bloat the results table. `sortBy` defines by which field a cell with multiple entries will be sorted. + +#### Facets +``` +"facets": { + "id": { + "sortByPredicate": "ex:id" + }, + "name": { + "containerClass": "one", + "facetType": "text", + "filterType": "textFilter", + "sortByPredicate": "ex:hasName/rdfs:label", + "textQueryProperty": "ex:hasName/rdfs:label" + }, +} +``` + +Under facets the filter facets on the left side of the result components get defined. `sortByPredicate` can also be defined here which declares which predicate should be used to sort on the matching column. + +Facets can use a variety of default components and filters. Custom facet components and filters are also supported as documented in [custom components](./CustomComponents.md). + +--- + diff --git a/docs/pages/PortalConfig.md b/docs/pages/PortalConfig.md new file mode 100644 index 00000000..00c773b4 --- /dev/null +++ b/docs/pages/PortalConfig.md @@ -0,0 +1,160 @@ +# Portal Config + +The `protalConfig.js` file is the entry point of your sampo config. In here you define all the different perspectives your app has, as well as some layout of your front page. (Note that all manner of layout and styling config in here will be moved to css in future versions). + +A lot of the config options in here are remnants of older sampo projects that do not really fit into the generic model for version 4 onwards. These will be removed in future versions. Only the important config options are described here. It can happen that sampo will not run without a value passed for some of these legacy configs, in those cases simply use a default value as in the example configs here. + +## Content config + +``"portalID": "sampo",`` the portalID essentially defines which config directory sampo will read and use. + +--- + +``"staticsUrl": "",`` with a staticsUrl you can define a different url where sampo should look for your statics such as images and icons. If set to nothing sampo will look for them under `/assets/`. + +--- + +``` +"perspectives": { + "searchPerspectives": [ + "corporations", + "securities", + "infoPage" + ], + "onlyInstancePages": [ + "corporationNames", + "legalForms", + "addresses" + ] +}, +``` +The perspectives config defines all the search perspectives you want to use and by default display in your topbar. Perspectives that are only instance pages also need to be listed here. + +--- + +``` +"localeConfig": { + "defaultLocale": "en", + "availableLocales": [ + { + "id": "en", + "label": "English", + "filename": "localeEN.json" + }, + { + "id": "nl", + "label": "Nederlands", + "filename": "localeNL.json" + } + ] +}, +``` +Here you define the different locales your app uses. Each locale will have its own json file in which you define all the text content of your portal. + +--- + +### Layout config +Note that this layout config will likely significantly change in version 5, but for now this is how it works. +``` +"layoutConfig": { + ... +} +``` +Under the layoutConfig object you will define everything to do with the layout of your landing page. + +``` +"customCssFile": "custom.css", +``` +A custom css file can be provided that will override default styling. It is recommended to use this over defining color palette etc in the `portalConfig.json` file as ideally styling should not be part of the json, but it is still possible for now as seen below. + +``` +"colorPalette": { + "primary": { + "main": "#2a2521" + }, + "secondary": { + "main": "#9e1b1e" + } +}, +"hundredPercentHeightBreakPoint": 900, +"reducedHeightBreakpoint": 1920, +"tabHeight": 58, +"paginationToolbarHeight": 37, +"tableFontSize": "0.8rem", +``` + +--- + +``` +"topBar": { + "showSearchField": false, + "logoImage": "", + "logoTextTransform": "none", + "hideLogoTextOnMobile": true, + "showLanguageButton": true, + "showFeedbackButton": false, + "externalInstructions": false, + "externalAboutPage": false, + "reducedHeight": 48, + "defaultHeight": 64, + "mobileMenuBreakpoint": 1360, + "infoDropdown": [ + { + "id": "infoPage", + "externalLink": false, + "translatedText": "infoPage", + "internalLink": "/info" + }, + ... + ] +}, +``` +The topbar also has a bunch of styling options that will be removed in version 5. +What is important however is `infoDropdown` where you can define what pages to display in the info dropdown on the right side of the topbar. You can define these pages yourself in your locales (see [locales](./Locales.md)). + +--- + +``` +"mainPage": { + "bannerImage": "main_page/mmm-banner.jpg", + "bannerBackground": "#f3eee3", + "bannerMobileHeight": 150, + "bannerReducedHeight": 220, + "bannerDefaultHeight": 300, + "wrapSubheading": true, + "perspectives": [ + { + "id": "part1", + "cardsPerRow": 2, + "perspectives": [ + "corporations", + "securities" + ] + } + ] +}, +``` +For the main part of the landing page what perspectives to show where can be defined. In locales again headers and texts can be set. + +--- + +``` +"footer": { + "reducedHeight": 54, + "defaultHeight": 64, + "images": [ + { + "id": "ugent", + "image": "logos/ugent.webp", + "href": "https://www.ugent.be/en", + "alt": "Ghent University logo", + "width": 55, + "height": 44 + }, + ... + ] +} +``` + +In the footer sampo by default supports putting whatever images with href you want. Again custom css could be used if more customisation is needed. + diff --git a/docs/pages/SparqlConfig.md b/docs/pages/SparqlConfig.md new file mode 100644 index 00000000..8bf0dcf2 --- /dev/null +++ b/docs/pages/SparqlConfig.md @@ -0,0 +1,110 @@ +# Sparql queries config + +## How queries get constructed + +The sampo server constructs its queries starting from a general query structure with tags in it like ````. +Queries get constructed by replacing these tags with query sections depending on the selected perspective and facets. + +General query structures can be overwritten as described in [overwriting general queries](./CustomComponents.md#overwriting-general-queries). + +In version 5 of sampo we aim to replace this system with a set default profiles, each profile being a specifically made +for a particular type of sparql endpoint (Jena, Qlever, Ontop, ...). + +### Facet results queries +```sparql +SELECT * +WHERE { + { + # score and literal are used only for Jena full text index + SELECT DISTINCT ?id ?score ?literal { + + VALUES ?facetClass { } + ?id ?facetClass . + + } + + +} +FILTER(BOUND(?id)) + +} +``` +This is the default facet results query. The inner query selects all the ids matching the class of the perspective being queried where all selected filters match. ```` gets replaced by the properties query block referenced +in the `propertiesQueryBlock` of the perspective config. The properties query block should query all the fields that need to be displayed starting from the `?id` which will always be bound. Different properties that can be matched indepentently should be joined using `union` statements. + + +#### Creating objects +By default, when doing paginated result queries sampo will use a mapper that transforms bound variables like this: +``?name__id``, ``?name__prefLabel`` into objects like this: ``"name": {"id": "value", "prefLabe": "value"}``. +When defining properties as described [here](./PerspectiveConfig.md#properties) you can use `"valueType": "object"`. +Using this will make the client display the `prefLabel` value and allow you to use `sortBy` for multiple values within a cell. + + +--- + +### Instance queries +```sparql +SELECT * { + BIND( as ?id) + + +} +``` +The default instance query binds the selected instance's id and then queries your defined properties block for it. + +--- + +### Count queries +```sparql +SELECT (COUNT(DISTINCT ?id) as ?count) +WHERE { + + VALUES ?facetClass { } + ?id ?facetClass . +} +``` +The default count query essentially performs a count on the inner query of the facet results query. This means it +correctly counts all the unique IDs that will be queried in the perspective. + +--- + +### Facet values queries +```sparql +SELECT DISTINCT ?id ?prefLabel ?selected ?parent ?instanceCount { + { + { + SELECT DISTINCT (count(DISTINCT ?instance) as ?instanceCount) ?id ?parent ?selected { + # facet values that return results + { + + ?instance ?id . + + VALUES ?facetClass { } + ?instance ?facetClass . + + } + + BIND(COALESCE(?selected_, false) as ?selected) + } +GROUP BY ?id ?parent ?selected +} +FILTER(BOUND(?id)) + + +} + +} + +``` +The facet values query selects all the facet values that are still available matching the currently selected filters, and also marks which facet values have been selected. + +--- + + +### Why overwrite? +There are two common reasons to want to overwrite some of these default queries. + +The first is simply if your particular sparql endpoint has some quirks that do not align well with the default. + +The second is if you require significantly more customisation for a complicated perspective. +See securities perspective of [BelHisFirm](https://github.com/GhentCDH/BelHISFirm-Frontend) for an example. diff --git a/server/package-lock.json b/server/package-lock.json index 4526dde8..c4a7f851 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -17,6 +17,7 @@ "express-static-gzip": "^2.1.1", "flat": "^5.0.2", "js-yaml": "^3.13.1", + "jsonc-parser": "^3.3.1", "querystring": "^0.2.1", "simple-statistics": "^7.7.0", "swagger-ui-express": "^4.1.6" @@ -32,6 +33,7 @@ "@fortawesome/fontawesome-svg-core": "^1.2.34", "@fortawesome/free-solid-svg-icons": "^5.15.2", "babel-loader": "^8.2.2", + "babel-plugin-module-resolver": "5.0.2", "concurrently": "^6.3.0", "copy-webpack-plugin": "^7.0.0", "cross-env": "^7.0.0", @@ -3727,6 +3729,135 @@ "node": ">=8" } }, + "node_modules/babel-plugin-module-resolver": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-module-resolver/-/babel-plugin-module-resolver-5.0.2.tgz", + "integrity": "sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-babel-config": "^2.1.1", + "glob": "^9.3.3", + "pkg-up": "^3.1.0", + "reselect": "^4.1.7", + "resolve": "^1.22.8" + } + }, + "node_modules/babel-plugin-module-resolver/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/babel-plugin-module-resolver/node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/babel-plugin-module-resolver/node_modules/glob": { + "version": "9.3.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz", + "integrity": "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "minimatch": "^8.0.2", + "minipass": "^4.2.4", + "path-scurry": "^1.6.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/babel-plugin-module-resolver/node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/babel-plugin-module-resolver/node_modules/minimatch": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.7.tgz", + "integrity": "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/babel-plugin-module-resolver/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/babel-plugin-module-resolver/node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/babel-plugin-module-resolver/node_modules/pkg-up": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", + "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.13", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.13.tgz", @@ -6193,6 +6324,16 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/find-babel-config": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/find-babel-config/-/find-babel-config-2.1.2.tgz", + "integrity": "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.3" + } + }, "node_modules/find-cache-dir": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz", @@ -7796,6 +7937,12 @@ "node": ">=6" } }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -8203,6 +8350,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", + "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, "node_modules/mjolnir.js": { "version": "2.7.3", "resolved": "https://registry.npmjs.org/mjolnir.js/-/mjolnir.js-2.7.3.tgz", @@ -8878,6 +9035,40 @@ "dev": true, "license": "MIT" }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/path-to-regexp": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", @@ -9810,6 +10001,13 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz", + "integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==", + "dev": true, + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", diff --git a/server/package.json b/server/package.json index 435b29fd..39b62eb9 100644 --- a/server/package.json +++ b/server/package.json @@ -28,6 +28,7 @@ "express-static-gzip": "^2.1.1", "flat": "^5.0.2", "js-yaml": "^3.13.1", + "jsonc-parser": "^3.3.1", "querystring": "^0.2.1", "simple-statistics": "^7.7.0", "swagger-ui-express": "^4.1.6" diff --git a/server/src/index.js b/server/src/index.js index e6c6e0d1..083a6fb3 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -69,19 +69,28 @@ createBackendSearchConfig().then(backendSearchConfig => { // Serve the configs directory at /configs const configsPath = '/app/configs' + const isFile = async (p) => { + try { + return (await fs.promises.stat(p)).isFile() + } catch (err) { + return false + } + } app.use(`${apiPath}/configs`, async (req, res, next) => { const filePath = path.join(configsPath, req.path) - try { - const stats = await fs.promises.stat(filePath) - if (stats.isFile()) { + // Transparently fall back to a `.jsonc` sibling so config files can be named + // either `.json` or `.jsonc`; the client keeps requesting `.json` URLs. + if (!(await isFile(filePath)) && req.path.endsWith('.json')) { + const jsoncPath = path.join(configsPath, `${req.path}c`) + if (await isFile(jsoncPath)) { + req.url = req.url.replace(/\.json(\?|$)/, '.jsonc$1') return express.static(configsPath)(req, res, next) - } else { - console.log(`Requested path is not a file: ${filePath}`) - next() } - } catch (err) { + } + if (await isFile(filePath)) { + return express.static(configsPath)(req, res, next) + } else { console.log(`File not found or inaccessible: ${filePath}`) - // console.error(`Error accessing ${filePath}:`, err.message) next() } }) diff --git a/server/src/sparql/Utils.js b/server/src/sparql/Utils.js index 9a7d4f83..d2ad6c83 100644 --- a/server/src/sparql/Utils.js +++ b/server/src/sparql/Utils.js @@ -1,11 +1,41 @@ -import { readFile } from 'fs/promises' +import { readFile, access } from 'fs/promises' import path from 'path' import { has } from 'lodash' +import { parse, printParseErrorCode } from 'jsonc-parser' import * as generalQueries from '../sparql/SparqlQueriesGeneral' +// Resolve a config path, transparently falling back to a `.jsonc` (or `.json`) +// sibling when the requested extension does not exist on disk. This lets portal +// authors name their config files either `.json` or `.jsonc`. +const resolveConfigPath = async (configPath) => { + const fileExists = async (p) => { + try { + await access(p) + return true + } catch { + return false + } + } + if (await fileExists(configPath)) return configPath + let alternative + if (configPath.endsWith('.jsonc')) { + alternative = configPath.slice(0, -1) // .jsonc -> .json + } else if (configPath.endsWith('.json')) { + alternative = `${configPath}c` // .json -> .jsonc + } + if (alternative && await fileExists(alternative)) return alternative + return configPath // let readFile throw a meaningful ENOENT for the original path +} + export const loadConfig = async (fileName) => { - const configPath = path.join(__dirname, '..', '..', '..', 'configs', fileName) - return JSON.parse(await readFile(configPath, 'utf-8')) + const configPath = await resolveConfigPath(path.join(__dirname, '..', '..', '..', 'configs', fileName)) + const errors = [] + const config = parse(await readFile(configPath, 'utf-8'), errors, { allowTrailingComma: true }) + if (errors.length > 0) { + const details = errors.map(e => `${printParseErrorCode(e.error)} at offset ${e.offset}`).join(', ') + throw new Error(`Failed to parse config file ${configPath}: ${details}`) + } + return config } const loadQueryConfig = async (fileName) => {