Salesforce, Python, SQL, & other ways to put your data where you need it

Need event music? 🎸

Live and recorded jazz, pop, and meditative music for your virtual conference / Zoom wedding / yoga class / private party with quality sound and a smooth technical experience

Migrating Gigpress to Sanity & 11ty

15 Oct 2020 🔖 architecture jamstack web development python
💬 EN

Table of Contents

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:

  1. The table gigpress_shows, which consists of the following fields:
    • show_id (its primary key), show_artist_id (a cross-reference to the gigpress_artists table’s primary key), show_venue_id (a cross-reference to the gigpress_venues table’s primary key), show_tour_id (a cross-reference to the gigpress_tours table’s primary key),
    • show_date, show_multi, show_time (gets set to 00:00:01 by default), show_expire (gets cloned from show_date where show_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 to venue_name), show_locale (a legacy field before the venues table was created; corresponds to venue_city), show_country (a legacy field before the venues table was created; corresponds to venue_country), show_venue, show_venue_url, show_venue_phone
  2. 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
  3. The table gigpress_artists, which consists of the following fields:
    • artist_id (its primary key),
    • artist_name, artist_alpha, artist_url, `artist_order
  4. 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 applicable
  • location, if applicable (either a Place or a VirtualLocation), which in the case of Place is essentially a Gigpress venue
  • performer, if applicable (this would be an array of Person and MusicGroup records – bands can have their members listed with a member array of OrganizationRole sub-records)
  • offers, if applicable (this can hold an array but I’d probably just make it a single object with a name of “Tickets” or something, plus optional price, priceCurrency, and url properties – url having the value of Gigpress’s show_tix_url if it was applicable, and of Gigpress’s show_external_url as a fallback)
  • sameAs, if applicable (this is where I’d put the old show_external_url from Gigpress if it hadn’t already ended up in offers 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

/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}`
      }
    }
  }
}

/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,
      };
    },
  },
};

/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;
--- ---