A map-centric website backed by a Core Data project and Elasticsearch index.
- Node 20.x
- Netlify CLI
- Core Data Project
- Elasticsearch index (Open Geographies v1)
Copy the .env.example file to .env and enter required variables. For a local development setup, set TINA_PUBLIC_IS_LOCAL to "true" and TINA_LOCAL_CONTENT_PATH to "../"
Add a /public/config.dev.json file, which will be ignored by Git, to copy local config settings when starting the application. Use the config.json table below to configure your project.
To start, run:
npm install && netlify dev
Note: Do not commit any project specific changes to /public/config.json in this repository.
Note: Changes to config.json will require a re-build of the site.
npm run vitest
The accessibility tests must be run against an actual site (either host or local). Set the A11Y_HOST environment variable in the .env file and then run the following.
npm run playwright
The results will be output to playwright-report/index.html.
npm run playwright
*Includes accessibility tests
One server renders every atlas: the site is resolved per request from the
Host header (<slug>.<OG_BASE_DOMAIN>, <slug>.localhost) or OG_SITE_SLUG,
and its config comes from the console's public by-slug endpoint. Any other
domain name in the Host is looked up as an atlas's own domain (the console's
by-domain endpoint; in development <name>.<name>.localhost names too). When an
atlas has a connected domain, GET/HEAD requests to its platform address (and to
the domain's www pair) get a 301 to the same path there (cached for an hour); a preview link or cookie moves
along as ?preview= with a 302 that isn't cached. OG_BASE_DOMAIN is required
in production: without it every <slug>.<base> host would be looked up as a
domain and 404. A proxy in front must pass the original Host header. A host
that names no atlas (the apex, an IP address) gets the not-found page. The
X-Atlas-Slug override is honoured only with OG_TRUST_ATLAS_SLUG_HEADER=true
(a proxy that sets it and strips the client's): otherwise any client could get
one atlas's page cached under another's address. The atlas cache is capped at
OG_ATLAS_CACHE_MAX_ENTRIES (default 1,000) addresses. Build with
npm run build:server # also writes .br/.gz copies of the built assets
npm run serve # node scripts/serve.mjs (HOST, PORT)
and run with OG_CONSOLE_URL, OG_ELASTICSEARCH_URL and, where the
browser-facing Core Data URL doesn't resolve from the server (a container
beside the host), OG_CORE_DATA_INTERNAL_URL. The static-site prebuild
below does not apply. The Open Geographies engine's demo/ stack runs this
image alongside the host.
scripts/serve.mjs is Astro's standalone handler plus the Brotli/gzip copies
scripts/precompress.mjs writes at build time: the adapter sends everything
uncompressed, and the map page's scripts are ~7 MB as built (MapLibre and the
IIIF viewers), ~2 MB compressed. A proxy in front may still compress pages.
Bundle size: Performant's and Peripleo's packages don't declare
sideEffects: false, so astro.config.mjs tells Rollup their JavaScript is
side-effect free (CSS kept); scripts/single-maplibre.mjs replaces the
MapLibre copy baked into @peripleo/maplibre's build with the app's own
maplibre-gl (one instance, so the pmtiles:// protocol reaches the map); and
the IIIF viewers (@components/MediaGallery, Clover thumbnails) load only
when used. Lazy-load a package export through a one-export module of our own
(see src/components/MediaGallery.tsx): a dynamic import() of the package
itself needs its whole namespace and pulls everything in up front. Map page:
8.0 → 2.9 MB of script; home/pages 0.18 MB.
Languages: an atlas is in the languages of its config.i18n (default first;
the six prefixes Astro routes). The header's picker lists those only; another
routed prefix redirects to the default language. Pages, the home page and the
menu come from the bundle's content.translations[<locale>] and
navigations[<locale>], falling back to the default language per page, with
site links in them moved to the visitor's language; pages carry hreflang
alternates. Interface text (buttons, filters) is English: the renderer's
strings ship only English defaults and core-data's components are fixed to
English.
Each atlas also serves /robots.txt and /sitemap.xml (its pages, searches
and published records' detail pages; an index of /sitemap/<n>.xml files over
40,000 addresses), and none for a draft.
On GitHub, create a new content repository. The posts, paths, and pages records you create will be stored here, as well as any i18n, your project configuration, and default user accounts.
Create a directory structure in the content repository as follows. The actual files are just examples, we'll go over adding required files in the next sections.
content
├── branding
│ ├── branding.json
├── i18n
│ ├── en.json
├── pages
│ ├── Home.mdx
├── paths
│ ├── MyPath.mdx
│ ├── AnotherPath.mdx
├── posts
│ ├── FirstPost.mdx
│ ├── SecondPost.mdx
├── settings
│ ├── config.json
├── users
│ ├── index.json
Copy the /data/users.json file into your content repository to /content/users/index.json. This will seed TinaCMS with the initial set of user accounts, which can be used to setup accounts for other users, then removed. Skip this step if you are using an SSO provider or institutional IdP to manage users instead.
Create the following /content/branding/branding.json file:
{
"title": "My Awesome Project",
"footer": {
"allow_login": true
}
}The title attribute will be set as the <title> element of the HTML page. Setting footer.allow_login to true will add Core Data and TinaCMS login buttons to the content footer. Additional branding options can be configured via TinaCMS.
Copy the /public/config.json file into your content repository to /content/settings/config.json and adjust the configuration as desired.
| Key | Type | Description |
|---|---|---|
| content | Object | TinaCMS content collections configuration |
| content.collections | Array | Array of content keys to allow for data entry and routing: "posts", "paths" |
| content.localize_pages | Boolean | If true pages content will be pulled from a locale directory (e.g. /en/My-Awesome-Page.mdx) |
| content.posts_config | Object | Configuration for posts categories and workflow |
| content.posts_config.categories | Array | List of categories by which posts can be filtered |
| content.posts_config.drafts | Boolean | If true will enable a publish toggle on posts and only display published content on the front end |
| content.posts_config.layout | String | Layout for the posts page. Options are list and grid. |
| content.posts_config.sort_by | Object | Post sorting options |
| content.posts_config.sort_by.name | String | Field to sort by |
| content.posts_config.sort_by.direction | String | asc or desc |
| core_data | Object | Core Data configuration |
| core_data.url | String | URL of the Core Data application |
| core_data.project_ids | Array | Array of Core Data project IDs as strings |
| detail_pages | Object | Configuration for the detail pages. <modelname> can be any of events, instances, items, organizations, people, places, taxonomies, or works |
| detail_pages.models..editioncrafter_config | Object | Optional configuration for an EditionCrafter viewer (requires associated FairCopy Cloud project) |
| detail_pages.models..editioncrafter_config.faircopy_base_url | String | Base for FairCopy Cloud API |
| detail_pages.models..editioncrafter_config.faircopy_project_id | String | FairCopy Cloud project ID |
| detail_pages.models..editioncrafter_config.transcription_types | Array | List of objects of the form { "key": "<some key>", "label": "<some label>" } that will be used as the transcription types fed to EditionCrafter. |
| detail_pages.models..editioncrafter_config.xml_id_field | String | UUID of the user defined field on the model that gives the FairCopy ID (xml:id) of the corresponding document |
| detail_pages.models..related_manifest | Object | Manifest to render in a IIIF Viewer at the top of the event detail page |
| detail_pages.models..related_manifest.model | String | Model to use for the related manifest |
| detail_pages.models..related_manifest.relationship | String | ID of the relationship to display in the viewer |
| detail_pages.models..resources_field | String | A string of the form <relationship UUID>.<field UUID>, where the relationship is with a model of type item and the field is a UDF on that item model that contains full URLs to resources pages. |
| detail_pages.relationship_fields.events | Array | List of fields to display in the related record components for related events |
| detail_pages.relationship_fields.instances | Array | List of fields to display in the related record components for related instances |
| detail_pages.relationship_fields.items | Array | List of fields to display in the related record components for related items |
| detail_pages.relationship_fields.works | Array | List of fields to display in the related record components for related works |
| i18n | Object | Astro i18n configuration |
| i18n.default_locale | String | The default locale |
| i18n.locales | Array | A list of all available locales |
| gallery | String | Link to the gallery IIIF manifest |
| layers | Array | A list of available map layers |
| layers.name | String | The name of the map layer to display in the UI |
| layers.layer_type | String | Map layer type: "raster", "vector", "geojson" or "georeference" |
| layers.url | String | URL to the map server |
| layers.overlay | Boolean | If true, map layer will be rendered as overlay layer. If false, map layer will be rendered as base layer |
| result_filtering | Object | Lists of fields and relationships to exclude per model. |
| result_filtering.events.exclude | Array | List of attributes, user defined fields and associations to be excluded from the search detail panel and detail pages. See Search detail filtering |
| result_filtering.instances.exclude | Array | List of attributes, user defined fields and associations to be excluded from the search detail panel and detail pages. See Search detail filtering |
| result_filtering.items.exclude | Array | List of attributes, user defined fields and associations to be excluded from the search detail panel and detail pages. See Search detail filtering |
| result_filtering.organizations.exclude | Array | List of attributes, user defined fields and associations to be excluded from the search detail panel and detail pages. See Search detail filtering |
| result_filtering.people.exclude | Array | List of attributes, user defined fields and associations to be excluded from the search detail panel and detail pages. See Search detail filtering |
| result_filtering.places.exclude | Array | List of attributes, user defined fields and associations to be excluded from the search detail panel and detail pages. See Search detail filtering |
| result_filtering.works.exclude | Array | List of attributes, user defined fields and associations to be excluded from the search detail panel and detail pages. See Search detail filtering |
| search | Array | Search UI configuration |
| search.facets | Array | Search facets configuration. |
| search.facets.icon | String | Icon to display in the facet header |
| search.facets.name | String | Name of the facet field as indexed (e.g. types) |
| search.facets.type | String | Search facet type: "list", "range", or "select" |
| search.geosearch | Boolean | If true, the "filter by map bounds" facet will be available in the map interface. |
| search.map | Object | Map configuration |
| search.map.cluster_radius | Number | If provided, map points will be clustered for the given radius (in miles). This option is only valid if the map.geometry property contains Lat/Lng coordinates. |
| search.map.geometry | String | Path to the attribute in the search document that contains the GeoJSON data to be displayed on the map |
| search.map.properties | String | Path to the attribute in the search document that contains the properties object for the GeoJSON |
| search.map.max_zoom | Number | The maximum zoom level to allow when the map view transitions to a set of bounds (a single place, or mulitple places). |
| search.map.zoom_to_place | Boolean | If true or not specified, clicking on an individual place marker or search result will zoom the map to its location (using the max zoom). |
| search.name | String | The name of the search index configuration. |
| search.result_card | Object | Search result card configuration |
| search.result_card.title | String | Path to the value in the search document that should be used as the card title. This value can contain nested objects (e.g. <relationship-id>.0.name). |
| search.result_card.attributes | Array | Attributes to display in the search list and table |
| search.result_card.attributes.name | String | Path to the value in the search document that should be used to look up the column value. This value can contain nested objects (e.g. <relationship-id>.0.name). |
| search.result_card.attributes.icon | String | Name of the icon that should displayed next to the value in the search list. If not provided, a bullet point will be used. |
| search.result_card.attributes.parser | String | Specifies the parser to use to format the value. |
| search.result_card.relationships | Array | Relationships to display on the cards in the list and grid searches |
| search.result_card.tags | Array | Tags to display as pills at the top of each item in the search list |
| search.result_card.tags.name | String | Name of the attribute to pull the tag value from |
| search.result_card.tags.parser | String | Specifies the parser to use to format the value. |
| search.result_card.tags.primary | Boolean | Whether the tag should be shown in the primary site color |
| search.result_card.tags.secondary | Boolean | Whether the tag should be shown in the secondary site color |
| search.route | String | The navigation route to use when clicking on a search result card (e.g. "/places", "/organizations", etc) |
| search.table | Boolean | If false, will suppress the table view for search results |
| search.timeline | Object | Timeline configuration |
| search.timeline.date_range_facet | String | Path to the date range facet field in the search document that will be used as the basis for the timeline. Required for the timeline to appear. |
| search.timeline.event_path | String | Path to the event relation in the search document. Required only if Event is not the primary model of this search index configuration. |
| search.type | String | If map, the map search component will be used. If image, list, or grid, the non-map search component will be used with the corresponding layout. Defaults to map when not present. |
| search.elasticsearch | Object | Elasticsearch search settings (no credentials: the connection is server-side, OG_ELASTICSEARCH_URL) |
| search.elasticsearch.index_name | String | Name of the shared Elasticsearch index |
| search.elasticsearch.model_ids | Array | Project model ids this search is over (applied as a server-side filter with core_data.project_ids) |
| search.elasticsearch.facet_attributes | Array | Facet attributes, in display order: a field name, or { attribute, field, type } for numeric/date ranges |
| search.elasticsearch.sort_attributes | Array | Additional sorts, as { name, field, order }; A–Z / Z–A on name.keyword are always available |
See Configuration Schema for more detailed information.
In your developer settings, create a new personal access token with access to your newly created repository with read/write permissions to code.
If using media uploads, you'll need to create a new IAM user to access the S3 bucket for media storage. All media for TinaCMS content is stored in the core-data-tina-cms bucket and access to sub-folders is given to users. Create a new user with the following inline policy:
{
"Version":"2012-10-17",
"Statement": [
{
"Sid": "AllowListingOfFolder",
"Action": ["s3:ListBucket"],
"Effect": "Allow",
"Resource": ["arn:aws:s3:::core-data-tina-cms"],
"Condition":{"StringLike":{"s3:prefix":["<folder-name>/*"]}}
},
{
"Sid": "AllowAllS3ActionsInFolder",
"Effect": "Allow",
"Action": ["s3:*"],
"Resource": ["arn:aws:s3:::core-data-tina-cms/<folder-name>/*"]
}
]
}
Replace <folder-name> with the name of the folder in the S3 bucket where the media should be stored. As a naming convention, the name of the folder should be the same as the name of the GitHub repository created above.
After the user is created, use the "Security Credentials" tab to create an access key/secret for the user.
Create a new site on Netlify deployed from the core-data-places repository. Set all of the environment variables in .env.example as appropriate. Currently, Core Data Places can only be hosted on Netlify in "server" mode, as the TinaCMS functions are dependent on Netlify functions.
See Keycloak documentation for single sign on.
Core Data Places can be built in "static" mode, which will remove the dependency on an existing Core Data project. See static deploy documentation.
For more information on upgrading your existing Core Data Places site to a newer version, see the update notes section.
To find the UUID values for Core Data user-defined fields and relationships, the "Identifier" column can be added to the respective lists within the Core Data application under Project Settings > Configure.