From 1ff8748bb950ea6cdd285e4d8e6458fda93b4cce Mon Sep 17 00:00:00 2001 From: workprentice <257153108+workprentice@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:32:39 +0000 Subject: [PATCH] Add schema.org/Dataset support for pages publishing original data Adds a dataset-entity.html collector, following the existing itemlist-entity.html opt-in pattern: a page opts in with a "dataset" object in frontmatter (name, description, license, datePublished, keywords, variableMeasured, temporalCoverage, isAccessibleForFree, distribution), and the collector returns an empty dict (a no-op) for every page that hasn't opted in. Wires into graph-builder.html the same way itemlist and the comparison table do: collected before the WebPage entity is finalized so hasPart can point at it, then appended to the graph under its own @id. creator defaults to the shared Pulumi Organization @id already defined on every page, with an optional creator_name override for data attributed to a named person or team. This is prep for the State of IaC 2026 survey report, an upcoming page publishing original survey statistics: Dataset markup is what makes that data machine-extractable for search and AI systems, distinct from the Article/FAQPage markup a page like that would otherwise fall back to. --- .../schema/collectors/dataset-entity.html | 114 ++++++++++++++++++ layouts/partials/schema/graph-builder.html | 21 ++++ 2 files changed, 135 insertions(+) create mode 100644 layouts/partials/schema/collectors/dataset-entity.html diff --git a/layouts/partials/schema/collectors/dataset-entity.html b/layouts/partials/schema/collectors/dataset-entity.html new file mode 100644 index 000000000000..d49c34ec046c --- /dev/null +++ b/layouts/partials/schema/collectors/dataset-entity.html @@ -0,0 +1,114 @@ +{{/* Returns a Dataset entity for a page that publishes original data (survey + results, benchmark numbers, usage statistics), driven entirely by a + `dataset` object in frontmatter so it never needs to parse markdown or + guess at what counts as "data" on a page. Opt in per page with: + + dataset: + name: "State of IaC 2026 Survey Dataset" + description: "Aggregated results from Pulumi's 2026 State of Infrastructure as Code survey." + license: "https://creativecommons.org/licenses/by/4.0/" + date_published: "2026-09-15" + keywords: + - "infrastructure as code" + - "survey" + variable_measured: + - "IaC tool adoption" + - "team size" + temporal_coverage: "2026" + is_accessible_for_free: true + distribution: + - content_url: "https://www.pulumi.com/state-of-iac/2026/data.csv" + encoding_format: "text/csv" + + Every field beyond `name` is optional; the collector only emits what the + page actually declares. `creator` defaults to the shared Pulumi + Organization @id (the same node graph-builder.html already defines on + every page) so a dataset page doesn't have to redeclare organization + identity, but a page can override it with an explicit + `dataset.creator_name` when the data was produced by a named person or + third party. Returns an empty dict when `.Params.dataset` or + `.Params.dataset.name` is absent, so this is a safe no-op on every page + that hasn't opted in \u2014 the same low-false-positive pattern as + itemlist-entity.html and faq-entity.html. */}} + +{{ $entity := dict }} + +{{ if and .Params.dataset .Params.dataset.name }} + {{ $d := .Params.dataset }} + + {{ $entity = dict + "@type" "Dataset" + "name" $d.name + "url" .Permalink + }} + + {{ with $d.description }} + {{ $entity = merge $entity (dict "description" .) }} + {{ end }} + + {{ with $d.license }} + {{ $entity = merge $entity (dict "license" .) }} + {{ end }} + + {{ with $d.date_published }} + {{ $entity = merge $entity (dict "datePublished" .) }} + {{ end }} + + {{ with $d.date_modified }} + {{ $entity = merge $entity (dict "dateModified" .) }} + {{ end }} + + {{ with $d.keywords }} + {{ $entity = merge $entity (dict "keywords" .) }} + {{ end }} + + {{ with $d.variable_measured }} + {{ $entity = merge $entity (dict "variableMeasured" .) }} + {{ end }} + + {{ with $d.temporal_coverage }} + {{ $entity = merge $entity (dict "temporalCoverage" .) }} + {{ end }} + + {{ with $d.spatial_coverage }} + {{ $entity = merge $entity (dict "spatialCoverage" .) }} + {{ end }} + + {{ if isset $d "is_accessible_for_free" }} + {{ $entity = merge $entity (dict "isAccessibleForFree" $d.is_accessible_for_free) }} + {{ end }} + + {{/* creator: named person/org override takes precedence over the shared + Pulumi Organization @id, since a survey dataset is still Pulumi's own + work either way \u2014 this only changes who schema.org says produced it. */}} + {{ if $d.creator_name }} + {{ $entity = merge $entity (dict "creator" (dict "@type" "Organization" "name" $d.creator_name)) }} + {{ else }} + {{ $entity = merge $entity (dict "creator" (dict "@id" "https://www.pulumi.com/#organization")) }} + {{ end }} + + {{/* distribution: each item becomes a DataDownload node. contentUrl is + required by schema.org/DataDownload; skip an item entirely if it's + missing one rather than emitting a malformed node. */}} + {{ with $d.distribution }} + {{ $downloads := slice }} + {{ range . }} + {{ if .content_url }} + {{ $download := dict + "@type" "DataDownload" + "contentUrl" .content_url + }} + {{ with .encoding_format }} + {{ $download = merge $download (dict "encodingFormat" .) }} + {{ end }} + {{ $downloads = $downloads | append $download }} + {{ end }} + {{ end }} + {{ if gt (len $downloads) 0 }} + {{ $entity = merge $entity (dict "distribution" $downloads) }} + {{ end }} + {{ end }} + +{{ end }} + +{{ return $entity }} diff --git a/layouts/partials/schema/graph-builder.html b/layouts/partials/schema/graph-builder.html index 5b469004b593..69503c1a3419 100644 --- a/layouts/partials/schema/graph-builder.html +++ b/layouts/partials/schema/graph-builder.html @@ -57,6 +57,22 @@ {{ end }} {{ end }} +{{/* Collect Dataset schema for a page publishing original data (survey results, + benchmark numbers, usage statistics) before finalizing WebPage so hasPart + can be wired. Driven entirely by a `dataset` object in frontmatter (see + dataset-entity.html) so it is an explicit per-page opt-in, the same + low-false-positive pattern as the `itemlist` block above. dataset-entity.html + returns an empty dict when `.Params.dataset.name` is absent, so this is a + no-op for every page that hasn't opted in. */}} +{{ $dataset := dict }} +{{ if .Params.dataset }} + {{ $dataset = partial "schema/collectors/dataset-entity.html" . }} + {{ if and $dataset (ne $dataset (dict)) }} + {{ $dataset = merge $dataset (dict "@id" "#dataset") }} + {{ $webpage = merge $webpage (dict "hasPart" (dict "@id" "#dataset")) }} + {{ end }} +{{ end }} + {{/* What-is and docs pages get Article/TechArticle as their main entity; if the page also has a "Frequently asked questions"-style Q&A section, expose it as a FAQPage entity in the same graph under its own @id. Pages that are themselves @@ -427,6 +443,11 @@ {{ $graph = $graph | append $itemList }} {{ end }} +{{/* Add Dataset to graph (collected earlier to wire hasPart into WebPage) */}} +{{ if and $dataset (ne $dataset (dict)) }} + {{ $graph = $graph | append $dataset }} +{{ end }} + {{/* Add video schema if page contains YouTube videos */}} {{ if not .IsHome }} {{ $videoEntity := partial "schema/collectors/video-entity.html" . }}