Migrating Gigpress to Sanity & 11ty
15 Oct 2020
TO DO: BLAH BLAH BLAH INTRO
Gigpress tables
Gigpress installs a schema called gp_db
into your MySQL database management system. In it are X tables, as follows:
- The table
gigpress_shows
, which consists of the following fields:show_id
(its primary key),show_artist_id
(a cross-reference to thegigpress_artists
table’s primary key),show_venue_id
(a cross-reference to thegigpress_venues
table’s primary key),show_tour_id
(a cross-reference to thegigpress_tours
table’s primary key),show_date
,show_multi
,show_time
(gets set to00:00:01
by default),show_expire
(gets cloned fromshow_date
whereshow_multi
is null),show_price
,show_tix_url
,show_tix_phone
,show_ages
,show_notes
,show_related
,show_status
,show_external_url
,show_tour_restore
,show_address
(a legacy field before the venues table was created; corresponds tovenue_name
),show_locale
(a legacy field before the venues table was created; corresponds tovenue_city
),show_country
(a legacy field before the venues table was created; corresponds tovenue_country
),show_venue
,show_venue_url
,show_venue_phone
- The table
gigpress_venues
, which consists of the following fields:venue_id
(its primary key),venue_name
,venue_address
,venue_city
,venue_state
,venue_postal_code
,venue_country
,venue_url
,venue_phone
- The table
gigpress_artists
, which consists of the following fields:artist_id
(its primary key),artist_name
,artist_alpha
,artist_url
, `artist_order
- The table
gigpress_tours
, which consists of the following fields:tour_id
(its primary key),tour_name
,tour_status
NOTE TO SELF: default_title
setting for blog post was %artist% at %venue% on %date%
All Robert ever used in a meaningful way were the shows and venues table.
More specifically, here’s approximately what I actually exported with SELECT *
against the shows and venues tables:
show_id | show_artist_id | show_venue_id | show_tour_id | show_date | show_multi | show_time | show_expire | show_price | show_tix_url | show_tix_phone | show_ages | show_notes | show_related | show_status | show_external_url | show_tour_restore | show_address | show_locale | show_country | show_venue | show_venue_url | show_venue_phone |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
1 | 1 | 1 | 0 | 2020-01-01 | 0 | 16:00:00 | 2020-01-01 | DESCRIPTION 1 | 0 | active | http://coolfestival.com |
0 | NULL | NULL | NULL | NULL | NULL | NULL | ||||
2 | 1 | 2 | 0 | 2020-01-02 | 0 | 11:30:00 | 2020-01-02 | DESCRIPTION 2 | 0 | active | 0 | NULL | NULL | NULL | NULL | NULL | NULL | |||||
3 | 1 | 1 | 0 | 2020-01-03 | 0 | 16:00:00 | 2020-01-03 | DESCRIPTION 3 | 0 | active | http://greatconference.com |
0 | NULL | NULL | NULL | NULL | NULL | NULL |
venue_id | venue_name | venue_address | venue_city | venue_state | venue_postal_code | venue_country | venue_url | venue_phone |
---|---|---|---|---|---|---|---|---|
1 | Birdland | 315 W 44th St #5402 | New York | NY | 10036 | https://birdlandjazz.com |
(212) 581-3080 | |
2 | Yoshi’s | 510 Embarcadero West | Oakland | CA | 94607 | https://yoshis.com |
(510) 238-9200 |
Schema.org schema
I like the idea of events that can refer to 0 or 1 venues, so I kept that aspect of my data model from Gigpress.
Beyond that, I started poking around Schema.org’s MusicEvent
structured data definition and decided that for every event listed on the new web site, I wanted Google to pick up the following information:
name
(required by Google),eventStatus
(required by Google),startDate
(required by Google) and, if applicable,endDate
,url
(the event’s dedicated URL on his own web site)description
, if applicable;image
(either a string value populated with a URL or an ImageObject), if applicable;isAccessibleForFree
, if applicablelocation
, if applicable (either a Place or a VirtualLocation), which in the case ofPlace
is essentially a Gigpress venueperformer
, if applicable (this would be an array of Person and MusicGroup records – bands can have their members listed with amember
array of OrganizationRole sub-records)offers
, if applicable (this can hold an array but I’d probably just make it a single object with aname
of “Tickets” or something, plus optionalprice
,priceCurrency
, andurl
properties –url
having the value of Gigpress’sshow_tix_url
if it was applicable, and of Gigpress’sshow_external_url
as a fallback)sameAs
, if applicable (this is where I’d put the oldshow_external_url
from Gigpress if it hadn’t already ended up inoffers
and wasn’t identical to the venue URL or the show tix URL)
Sanity schemas
To that end, here’s what I ended up creating in Sanity:
TO DO: FIGURE OUT SLUGS AND EVENTS
Venue-related schemas
/schemas/objects/json_ld_focused/address.js
export default {
name: 'address',
title: 'Address',
type: 'object',
fields: [
{name: 'street', type: 'string', title: 'Street number and name'},
{name: 'city', type: 'string', title: 'City'},
{name: 'state', type: 'string', title: 'State'},
{name: 'zip', type: 'string', title: 'Zip'},
{name: 'country', type: 'string', title: 'Country'},
]
}
/schemas/documents/json_ld_focused/venue.js
// Note: "venue" correspondes to "place" fulfilling "Place"-typed "location" in Schema.org, and therefore means a physical place.
// Not sure if better to render venueURL as "url" or "sameAs" property of "location" on MusicEvent in JSON-LD.
import icon from '../../../static/icons/location-city'
export default {
name: 'venue',
title: 'Venue',
type: 'document',
icon,
fields: [
{
name: 'title',
title: 'Venue Name',
description: 'Note: please do not create "venues" for online events',
type: 'string',
validation: (Rule) => Rule.required(),
},
{
name: 'address',
title: 'Venue Address',
type: 'address'
},
{
name: 'url',
title: 'Venue URL',
description: 'Example: https://www.birdlandjazz.com/',
type: 'url'
},
{
name: 'phone',
title: 'Venue Phone',
type: 'string'
}
], // End of "fields"
orderings: [
{
title: 'Sort venues by name',
name: 'nameAsc',
by: [
{field: 'title', direction: 'asc'}
]
}
],
preview: {
select: {
title: 'title',
street: 'address.street',
city: 'address.city',
state: 'address.state',
zip: 'address.zip',
},
prepare(selection) {
//const {title} = selection
const {title, street, city, state, zip, concatAddr} = selection
const fullAddress = (street ? street + ' ' : '') + (city ? city + ' ' : '') + (state ? state : '')
return {
title: `${title}`,
subtitle: `${fullAddress}`
}
}
}
}
Performer-related schemas
/schemas/documents/json_ld_focused/person.js
import icon from "../../../static/icons/pregnant-woman";
export default {
name: "person",
title: "Person",
type: "document",
icon,
fields: [
{
name: "personName",
title: "Person Name",
validation: (Rule) => Rule.required(),
type: "string",
},
{
name: "personURL",
title: "Home page URL",
type: "url",
},
{
name: "image",
type: "image",
title: "Featured Image",
options: {
hotspot: false, // <-- Defaults to false
storeOriginalFilename: false, // <-- Defaults to true
},
fields: [
{
name: "altText",
type: "string",
title: "Featured image alternative text",
description:
"How should a screen reader describe this to a blind person?",
options: {
isHighlighted: true, // <-- Is this field pulled out from behind the "Edit" button?
},
},
{
name: "attribution",
type: "string",
title: "Featured image attribution",
description:
"Is there a photographer who needs to be credited for rights to use this picture?",
options: {
isHighlighted: true, // <-- Is this field pulled out from behind the "Edit" button?
},
},
],
},
], // End of "fields"
orderings: [
{
title: "Sort venues by name",
name: "nameAsc",
by: [{ field: "personName", direction: "asc" }],
},
],
preview: {
select: {
name: "personName",
media: "image",
},
prepare(selection) {
const { name, media } = selection;
return {
title: `${name}`,
media: media,
};
},
},
};
/schemas/objects/json_ld_focused/performingGroupMember.js
export default {
name: 'performingGroupMember',
title: 'Group Member',
type: 'object',
fields: [
{
name: 'memberPerson',
title: 'Person',
type: 'reference',
to: {type: 'person'},
validation: (Rule) => Rule.required(),
},
{
name: 'roleName',
title: 'Role(s) in the group',
type: 'array',
of: [{type: 'string'}],
validation: (Rule) => Rule.unique(),
},
], // End of "fields"
preview: {
select: {
name: 'memberPerson.personName',
roles: 'roleName'
},
prepare(selection) {
const {name, roles} = selection
return {
title: `${name}`,
subtitle: `${JSON.stringify(roles)}`
}
}
}
}
/schemas/documents/json_ld_focused/performingGroup.js
import icon from "../../../static/icons/ios-people";
export default {
name: "performingGroup",
title: "Performing Group",
type: "document",
icon,
fields: [
{
name: "performingGroupName",
title: "Group Name",
type: "string",
validation: (Rule) => Rule.required(),
},
{
name: "groupURL",
title: "Group URL",
type: "url",
},
{
name: "additionalGroupURLs",
title: "Additional Group URLs",
description: "Facebook page, etc.",
type: "array",
of: [{ type: "url" }],
},
{
name: "members",
title: "Members",
type: "array",
type: "array",
of: [{ type: "performingGroupMember" }],
},
{
name: "groupAddress",
title: "Group City and State",
description: "Only enter city and state -- ignore street, zip, etc.",
type: "address",
},
{
name: "groupSubType",
type: "string",
title: "Group Type",
validation: (Rule) => Rule.required(),
options: {
list: [
{ title: "Music Group", value: "MusicGroup" },
{ title: "Theater Group", value: "TheaterGroup" },
],
},
},
{
name: "image",
type: "image",
title: "Featured Image",
options: {
hotspot: false, // <-- Defaults to false
storeOriginalFilename: false, // <-- Defaults to true
},
fields: [
{
name: "altText",
type: "string",
title: "Featured image alternative text",
description:
"How should a screen reader describe this to a blind person?",
options: {
isHighlighted: true, // <-- Is this field pulled out from behind the "Edit" button?
},
},
{
name: "attribution",
type: "string",
title: "Featured image attribution",
description:
"Is there a photographer who needs to be credited for rights to use this picture?",
options: {
isHighlighted: true, // <-- Is this field pulled out from behind the "Edit" button?
},
},
],
},
], // End of "fields"
orderings: [
{
title: "Sort groups by name",
name: "nameAsc",
by: [{ field: "performingGroupName", direction: "asc" }],
},
],
initialValue: {
groupSubType: "MusicGroup",
},
preview: {
select: {
name: "performingGroupName",
media: "image",
},
prepare(selection) {
const { name, media } = selection;
return {
title: name,
media: media,
};
},
},
};
Event-related schemas
/schemas/objects/json_ld_focused/timeOnly.js
const timePattern = /((1[0-2]|0?[1-9]):([0-5][0-9]) ?([AaPp][Mm]))/;
const timeErrorMessage = 'Times must be typed like "6:00 PM"';
export default {
name: "timeOnly",
title: "Time",
type: "object",
fields: [
{
name: "inputTime",
title: "Reminder: midnight is 12:00AM and noon is 12:00PM",
type: "string",
validation: (Rule) => Rule.regex(timePattern).error(timeErrorMessage),
},
],
};
export const getSeoDate = (readyDate, time12h) => {
if (!readyDate) {
return null;
}
if (!time12h) {
return `${readyDate}`;
}
let timeMatch = time12h.match(timePattern);
// If "2:30 PM", timeMatch[1] = "2:30pm", [2] = "2", [3] = "30", [4] = "PM"
const modifier = timeMatch[4];
let hours = timeMatch[2];
const minutes = timeMatch[3];
if (hours === "12") {
hours = "00";
}
if (modifier === "PM") {
hours = parseInt(hours, 10) + 12;
}
return `${readyDate}T${hours}:${minutes}:00`;
};
/schemas/documents/json_ld_focused/performance.js
The word event
was popping out funny colors in my text editors, so I decided not to risk it having special meanings in JavaScript and use the word performance
whereever an unoquoted keyword event
might appear in JavaScript. I’m still trying to use the word “event” everywhere I clearly can, though.
// Ideally, the schema.org JSON-LD object representing this MusicEvent will have an "offer" property with a single object in it.
// That object's "price" and "priceCurrency" can come from the event fields of the same name.
// That object's "url" can be determined as follows:
// If the event has a "ticketUrl," use it. (The idea is to have data like https://ticketmaster.com/bestfestival)
// If not, fall back to "infoUrl". (The idea is to have data like https://bestfestival.com)
// If not, fall back to "venue.url". (The idea is https://birdlandjazz.com)
// Consider setting that object's "name" to the phrase "Tickets". That's what Bandzoogle does.
// If "infoUrl" is not in use as the offer URL, put it as the string value for a "sameAs" property of the MusicEvent.
// The MusicEvent might also have a "location" property.
// No matter what, if "eventVenue.title" is populated, make it the string value for a "name" property of the "Place" object serving as a value for the MusicEvent's "location" property.
// No matter what, if "eventVenue.phone" is populated, make it the string value for a "telephone" property of the "Place" object serving as a value for the MusicEvent's "location" property.
// No matter what, if "eventVenue.url" is populated, make it the string value for a "sameAs" property of the "Place" object serving as a value for the MusicEvent's "location" property.
// If "venue.address" concat is non-null, be sure to use it as the string value for the
// "address" property of the "Place" object serving as a value for the MusicEvent's "location" property.
// If "venue.address" address components seem sufficient, consider appending them to Google Maps and turning that URL into a string value for the
// "hasMap" property of the "Place" object serving as a value for the MusicEvent's "location" property.
// If "price" is non-null and 0, consider setting "isAccessibleForFree" property on MusicEvent to true.
// Be sure to set MusicEvent's "name", "startDate", "status" (all required)
// Be sure to set MusicEvent's "endDate", "attendanceMode", "description", "image" if applicable
// Also set MusicEvent's "performer" array/object
// Be sure to set MusicEvent's "url" from its web page within this web site
// If "performer" is set, be sure to render a "performer" property for the MusicEvent with a MusicGroup / TheaterGroup object as its value.
// If person whose home page this is isn't in the "performer" array as an individual or member of a group, inject it as an individual at time of rendering "performer" value in JSON-LD.
import icon from "../../../static/icons/calendar";
import { getSeoDate } from "../../objects/json_ld_focused/timeOnly";
const validateStartEnd = (stDt, stTmObj, edDt, edTmObj) => {
const stTmMod = stTmObj && stTmObj.inputTime ? stTmObj.inputTime : "12:00 AM"; // Pretend null start time is first possible time
const edTmMod = edTmObj && edTmObj.inputTime ? edTmObj.inputTime : "11:59 PM"; // Pretend null end time is last possible time
const startDateTime = Date.parse(getSeoDate(stDt, stTmMod)); // Convert to datetime
const endDateTime = Date.parse(getSeoDate(edDt, edTmMod)); // Convert to datetime
if (!startDateTime || !endDateTime) {
// Short-circuit if one is empty
return true;
} else if (startDateTime <= endDateTime) {
return true;
} else {
return "Event cannot end before it begins";
}
};
export default {
name: "performance",
title: "Event",
type: "document",
icon,
fieldsets: [
{ name: "offer", title: "How to attend" },
{ name: "timePeriod", title: "Event date and time" },
],
fields: [
{
name: "eventSeoJson",
title: "Event SEO JSON",
type: "string",
readOnly: true,
},
{
name: "searchJson",
title: "Event Search-Index JSON",
type: "string",
readOnly: true,
},
{
name: "title",
title: "Event Title",
type: "string",
validation: (Rule) => Rule.required(),
},
{
name: "startDate",
title: "Start Date",
type: "date",
validation: (Rule) => [
Rule.required().error("Start date is required"),
Rule.custom((x, context) => {
return validateStartEnd(
x,
context.document.startTime,
context.document.endDate,
context.document.endTime
);
}),
],
options: {
dateFormat: "YYYY-MM-DD",
calendarTodayLabel: "Today",
},
fieldset: "timePeriod",
},
{
name: "startTime",
title: "Start Time",
type: "timeOnly",
validation: (Rule) => [
Rule.custom((x, context) => {
return validateStartEnd(
context.document.startDate,
x,
context.document.endDate,
context.document.endTime
);
}),
],
fieldset: "timePeriod",
},
{
name: "endDate",
title: "End Date",
type: "date",
validation: (Rule) => [
Rule.custom((endDt, context) => {
if (!endDt && context.document.endTime) {
return "If using end time, must enter an end date";
}
return true;
}),
Rule.custom((x, context) => {
return validateStartEnd(
context.document.startDate,
context.document.startTime,
x,
context.document.endTime
);
}),
],
options: {
dateFormat: "YYYY-MM-DD",
calendarTodayLabel: "Today",
},
fieldset: "timePeriod",
},
{
name: "endTime",
title: "End Time",
type: "timeOnly",
fieldset: "timePeriod",
validation: (Rule) => [
Rule.custom((endTm, context) => {
if (!context.document.endDate && endTm) {
return "If using end time, must enter an end date";
}
return true;
}),
Rule.custom((x, context) => {
return validateStartEnd(
context.document.startDate,
context.document.startTime,
context.document.endDate,
x
);
}),
],
},
{
name: "attendanceMode",
type: "string",
title: "Attendance mode",
description: "Real-world or online?",
validation: (Rule) => Rule.required(),
options: {
list: [
{ title: "Real-world", value: "OfflineEventAttendanceMode" },
{ title: "Online", value: "OnlineEventAttendanceMode" },
{
title: "Mixed (real-world audience simulcast online)",
value: "MixedEventAttendanceMode",
},
],
},
},
{
name: "venue",
title: "Venue (physical)",
description: "Leave blank for online events",
type: "reference",
to: { type: "venue" },
validation: (Rule) =>
Rule.custom((venueRef, context) => {
if (
context.document.attendanceMode === "OnlineEventAttendanceMode" &&
venueRef
) {
return "Leave venue blank for online events";
}
return true;
}),
},
{
name: "ticketUrl",
type: "url",
title: "Ticket / RSVP / access URL",
description:
"Examples:\r\nhttps://www.ticketmaster.com/bestfest, http://www.birdlandljazz.com/event/your-band-live-2020-02-14/, http://bestfest.com/register/",
fieldset: "offer",
validation: (Rule) =>
Rule.custom((tickUrl, context) => {
const evtMode = context.document.attendanceMode;
if (
(evtMode === "OnlineEventAttendanceMode" ||
evtMode === "MixedEventAttendanceMode") &&
!tickUrl &&
!context.document.infoUrl
) {
return 'Either "ticket" or "additional info" info URL is required for online events';
}
return true;
}),
},
{
name: "infoUrl",
type: "url",
title: "Additional event info URL",
description:
'Please LEAVE BLANK if physical venue\'s URL or "ticket/RSVP/access" URL suffices.\r\nMay be useful for, say, http://www.bestfest.com/ when tickets are through a 3rd party like Ticketmaster',
fieldset: "offer",
validation: (Rule) =>
Rule.custom((addlUrl, context) => {
const evtMode = context.document.attendanceMode;
if (
(evtMode === "OnlineEventAttendanceMode" ||
evtMode === "MixedEventAttendanceMode") &&
!addlUrl &&
!context.document.ticketUrl
) {
return 'Either "ticket" or "additional info" URL is required for online events';
}
return true;
}),
},
{
name: "price",
type: "number",
title: "Price",
description: "Leave off the dollar symbol",
fieldset: "offer",
validation: (Rule) => Rule.precision(2).positive(),
},
{
name: "priceCurrency",
type: "string",
title: "Currency",
fieldset: "offer",
options: {
list: [
{ title: "U.S. Dollars", value: "usd" },
{ title: "Canadian Dollars", value: "cad" },
],
},
},
{
name: "description",
title: "Event description",
type: "text",
},
{
name: "performer",
title: "Performers",
description:
"If attaching a group, please do not redundantly attach its members",
type: "array",
of: [
{
type: "reference",
to: [{ type: "performingGroup" }, { type: "person" }],
},
],
},
{
name: "status",
type: "string",
title: "Event Status",
validation: (Rule) => Rule.required(),
options: {
list: [
{ title: "Normal", value: "EventScheduled" },
{ title: "Cancelled", value: "EventCancelled" },
],
},
},
{
name: "image",
type: "image",
title: "Featured Image",
validation: (Rule) => Rule.required(),
options: {
hotspot: true, // <-- Defaults to false
storeOriginalFilename: false, // <-- Defaults to true
},
fields: [
{
name: "altText",
type: "string",
title: "Featured image alternative text",
description:
"How should a screen reader describe this to a blind person?",
options: {
isHighlighted: true, // <-- Is this field pulled out from behind the "Edit" button?
},
},
{
name: "attribution",
type: "string",
title: "Featured image attribution",
description:
"Is there a photographer who needs to be credited for rights to use this picture?",
options: {
isHighlighted: true, // <-- Is this field pulled out from behind the "Edit" button?
},
},
],
},
], // End of "fields"
initialValue: {
status: "EventScheduled",
ignoreStartDate: false,
ignoreEndDate: false,
},
orderings: [
{
title: "Sort events by startDate",
name: "startDateAsc",
by: [{ field: "startDate", direction: "asc" }],
},
],
preview: {
select: {
title: "title",
startDate: "startDate",
venueName: "venue.title",
mode: "attendanceMode",
},
prepare(selection) {
const { title, startDate, venueName, mode } = selection;
return {
title: `${startDate ? startDate : ""}: ${title ? title : ""}`,
subtitle: `${venueName ? venueName : ""} (${mode
.split("EventAttendanceMode")[0]
.replace("Offline", "Real-world")})`,
};
},
},
};
Glue schema
/schemas/schema.js
// First, we must import the schema creator
import createSchema from "part:@sanity/base/schema-creator";
// Then import schema types from any plugins that might expose them
import schemaTypes from "all:part:@sanity/base/schema-type";
// We import object and document schemas
import address from "./objects/json_ld_focused/address";
import performance from "./documents/json_ld_focused/performance";
import performingGroup from "./documents/json_ld_focused/performingGroup";
import performingGroupMember from "./objects/json_ld_focused/performingGroupMember";
import person from "./documents/json_ld_focused/person";
import timeOnly from "./objects/json_ld_focused/timeOnly";
import venue from "./documents/json_ld_focused/venue";
// Then we give our schema to the builder and provide the result to Sanity
export default createSchema({
// We name our schema
name: "default",
// Then proceed to concatenate our document type
// to the ones provided by any plugins that are installed
types: schemaTypes.concat([
// The following are document types which will appear in the studio.
performance,
performingGroup,
person,
venue,
// When added to this list, object types can be used as
// { type: 'typename' } in other document schemas
address,
performingGroupMember,
timeOnly,
]),
});
Python
Now I have to get my two spreadsheets Gigpress data (which I dumped to CSV by logging into the PHPMyAdmin panel of the server hosting Gigpress) into Sanity.
Python and Pandas to the rescue!
TO DO: COME UP WITH SOME CODE THAT IS NOT SO PERSONAL AND MAKES SLUGS FOR EVENTS
Eleventy
I’m still trying to come up with the perfect way of rendering a web site out of Sanity’s data, but 11ty looked promising because I liked the idea of using a “monorepo” for CMS & SSG configuration, potentially letting them share a codebase since I have so much “data-transforming business logic” I’d like to keep out of the “presentation” logic such as transforming Sanity data into Schema.org JSON-LD-formatted data for SEO (search engine optimization) purposes. 11ty configuration is done in JavaScript, and JavaScript can be used as an HTML-generation “templating language” in it, too. Plus, allegedly it builds fast (indeed, I was getting 6ms/page at 2,500 events, so it still builds in less than 20 seconds – that said, I might make 11ty only statically render upcoming event pages and have a serverless function be responsible for dynamically rendering past event pages so the links don’t break, and ceasing to hyperlink to those pages from the “past events” page so they don’t get hit too often – that’d boost build speeds even more).
I’m really not a fan of templating HTML output in JavaScript. I hate the way it doesn’t stick out because it’s all surrounded by string punctuation marks. I much prefer dedicated templating languages like Liquid or Nunjucks.
Luckily, 11ty lets me jump around at will among all of its templating languages pretty well. I can use JavaScript when I need to (because Nunjucks doesn’t allow parameterized includes and 11ty’s flavor of Liquid doesn’t either), but stick to templating languages I find easy to read everywhere else.
How I think 11ty works
From what I understand, any template-like file in any reasonably “magic” directory visible to 11ty will be parsed to see if plaintext output can be generated from it and written to an output file.
If such a file has “front matter” data attached to it that specifies a permalink
, it will attempt to create an output file by that name and write its output to that file.
If such a file has “front matter” data attached to it that specifies a layout
, 11ty will go looking for an appropriately-named template in _includes
and will wrap the part of that “layout” that refers to content
around the output asking for a “layout” before the output goes wherever it’s going next (e.g. to an output file).
Presentation
/src/_includes/layouts/base.njk
- HTML for all output pages
base.njk
is the heart of what every HTML-based page on this site looks like.
I’m pretty sure that having base.njk
in the 11ty special directory /src/_includes/layouts/
makes it so when I specify a value for layout
in “front matter”, I can refer to it as 'layouts/base'
.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<header>
<p>I'm a nav bar!</p>
</header>
<main{% if templateClass %} class="{{ templateClass }}"{% endif %}>
{{ content | safe }}
</main>
<footer>
<p>I'm a footer! A copyright lives here, or something.</p>
</footer>
</body>
</html>
/src/_includes/layouts/event.njk
- Inner HTML for individual event pages
I’m not sure this will get to stay in Nunjucks, but for now, here’s event.njk
, which, like base.njk
, wraps data in HTML.
However, note that unlike base.njk
, it has “front matter” specifying that its own output needs to be wrapped inside base
.
Also note that it doesn’t make any explicit use of content
, instead presuming that it will be passed a complex piece of data called event
with values attached to title
and description
properties/fields.
Finally, note that its “front matter” doesn’t specify a permalink
– this is not a template that is meant for rendering content ready to write to final output. It’s just a helper template, intended to be summoned by a template that actually knows where the final output will be going.
---
layout: layouts/base
---
<h1>{{ event.title }}</h1>
<h2>{{ event.description | safe }}</h2>
<p><a href="{{ '/' | url }}">? Home</a></p>
File generation
/src/loop_event_pages.njk
- File generator for individual event pages
From what I can tell, it’s pretty conventional in the world of 11ty to keep all your templates that specify a permalink
value right in the /src/
folder.
It turns out there’s no “magic naming” involved in these files – I was able to call one loop_event_pages.njk
and get 11ty to pick it up, even though nothing about my data or output has anything to do with that phrase.
This one doesn’t even have anything but front matter. It delegates all “HTML prettifying” work to /src/_includes/layouts/event.njk
as layouts/event
.
When 11ty builds, lots of files such as /dist/events/my-slug-1/index.html
will get generated – one for each piece of data in “all_events
” (we’ll get to where that comes from in a moment).
---
layout: layouts/event
pagination:
alias: event
data: all_events
size: 1
addAllPagesToCollections: false
permalink: events/{{event.slug}}/index.html
---
/src/singleton_upcoming_events.njk
- File generator for upcoming events listing
When 11ty builds, it might be nice to have a page at /dist/events/index.html
linking to all the upcoming events’ individual informational pages.
I’ll put another template file right in /src/
with permalink
in its front matter.
This time, it will have a fixed value and no “pagination,” so it will know to build just a single output file.
In this case, rather than giving it a dedicated “layout” file like I gave event, I’ll just refer to layout/base
to make sure it looks like it belongs on my web site and wrap the event details in HTML myself down below the front matter.
---
layout: 'layouts/base'
permalink: '/events/index.html'
---
<ul>
{% for event in upcoming_events %}
<li><a href="events/{{event.slug}}/">{{ event.title }}</a></li>
{% endfor %}
</ul>
Eventually, I’ll probably kick the HTML work to a dedicated “layout,” but honestly, I’m not even planning to make “singletons” like this.
In the end, I plan to do a loop over “flexipages” like I do over “events” and have a “flexipage” document type in Sanity for a WYSIWYG site-builder-like content authoring experience.
Such extreme “component-ization” is why I’ll probably eventually have to use JavaScript as a templating language down in the bowels of my 11ty config. But for now, yay Nunjucks.
Anyway, if you read the code, you might be wondering what on earth upcoming_events
is. Time to have a look!
Data fetching
/src/all_events.js
- Event data fetcher 1 of 2
const client = require("../utils/sanityClient.js");
module.exports = async function () {
const query = `
*[ _type == "performance" && !(_id in path("drafts.**")) ]{
_id,
slug,
title,
description,
attendanceMode,
startDate,
'startTime': startTime.inputTime,
venue->{title, address},
} | order(_id asc)
`;
const params = {};
let queryResult = await client.fetch(query, params);
queryResult = queryResult.map((item) => {
item.hello = "world"; // DEBUG LINE ONLY - Testing adding a "hello" property to every event before it hits 11ty
return item;
});
return queryResult;
};
/src/all_events.js
- Event data fetcher 2 of 2
I should probably break the shared code in these two modules into a helper module.
const client = require("../utils/sanityClient.js");
module.exports = async function () {
const today = new Date()
today.setDate(today.getDate() - 50); // DEBUG LINE ONLY - THE PANDEMIC KILLED HAVING ACTUAL UPCOMING EVENTS
const todayStr = new Date(today.getTime() - today.getTimezoneOffset() * 60000).toISOString().split("T")[0];
const query = `
*[ _type == "performance" && !(_id in path("drafts.**")) && startDate > '${todayStr}' ]{
_id,
slug,
title,
description,
attendanceMode,
startDate,
'startTime': startTime.inputTime,
venue->{title, address},
} | order(_id asc)
`;
const params = {};
let queryResult = await client.fetch(query, params);
queryResult = queryResult.map((item) => {
item.hello = "world";
return item;
});
return queryResult;
};
/src/utils/sanityClient.js
- Sanity connection helper
const sanityClient = require("@sanity/client");
require('dotenv').config()
const projectId = process.env.SANITY_PROJECT
const apiToken = process.env.SANITY_TOKEN
const datasetName = process.env.SANITY_DATASET
const client = sanityClient({
projectId,
dataset: datasetName,
token: apiToken,
useCdn: (process.env.NODE_ENV === 'production' && !apiToken), // Turns off a buildtime nastygram. Private datasets aren't in the CDN anyway.
})
module.exports = client;