From e43b442ebb9951116e7a5a388c1ea95e78eb5b7a Mon Sep 17 00:00:00 2001
From: Yisakor M
Date: Thu, 13 Aug 2026 20:02:13 -0700
Subject: [PATCH 1/3] Add OpenProcessing example sync tooling
---
package.json | 3 +-
src/scripts/openprocessing.ts | 121 ++++++++++++++++
src/scripts/sync-openprocessing.ts | 198 ++++++++++++++++++++++++++
src/utils/exampleAttribution.ts | 81 +++++++++++
test/scripts/openprocessing.test.ts | 39 +++++
test/utils/exampleAttribution.test.ts | 62 ++++++++
6 files changed, 503 insertions(+), 1 deletion(-)
create mode 100644 src/scripts/openprocessing.ts
create mode 100644 src/scripts/sync-openprocessing.ts
create mode 100644 src/utils/exampleAttribution.ts
create mode 100644 test/scripts/openprocessing.test.ts
create mode 100644 test/utils/exampleAttribution.test.ts
diff --git a/package.json b/package.json
index fe0122165a..3e2d1f8d1f 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,8 @@
"build:search": "tsx ./src/scripts/builders/search.ts",
"build:p5-version": "tsx ./src/scripts/p5-version.ts",
"custom:dev": "tsx ./src/scripts/branchTest.ts",
- "custom:cleanup": "tsx ./src/scripts/resetBranchTest.ts"
+ "custom:cleanup": "tsx ./src/scripts/resetBranchTest.ts",
+ "sync:openprocessing": "tsx ./src/scripts/sync-openprocessing.ts"
},
"engines": {
"node": ">=22.0.0"
diff --git a/src/scripts/openprocessing.ts b/src/scripts/openprocessing.ts
new file mode 100644
index 0000000000..388f148844
--- /dev/null
+++ b/src/scripts/openprocessing.ts
@@ -0,0 +1,121 @@
+const OPENPROCESSING_BASE_URL = "https://openprocessing.org/api";
+
+export interface OpenProcessingSketch {
+ visualID: number;
+ title?: string;
+ [key: string]: unknown;
+}
+
+export interface OpenProcessingCuration {
+ visualID?: number;
+ id?: number;
+ sketches?: OpenProcessingSketch[];
+ [key: string]: unknown;
+}
+
+interface OpenProcessingConfig {
+ token: string;
+ curationId: string;
+}
+
+function getConfig(): OpenProcessingConfig {
+ const token = process.env.OPENPROCESSING_TOKEN;
+ const curationId = process.env.OPENPROCESSING_CURATION_ID;
+
+ if (!token) {
+ throw new Error(
+ "Missing OPENPROCESSING_TOKEN environment variable."
+ );
+ }
+
+ if (!curationId) {
+ throw new Error(
+ "Missing OPENPROCESSING_CURATION_ID environment variable."
+ );
+ }
+
+ return {
+ token,
+ curationId,
+ };
+}
+
+async function request(
+ path: string,
+ options: RequestInit = {}
+): Promise {
+ const { token } = getConfig();
+
+ const response = await fetch(`${OPENPROCESSING_BASE_URL}${path}`, {
+ ...options,
+ headers: {
+ Accept: "application/json",
+ Authorization: `Bearer ${token}`,
+ ...options.headers,
+ },
+ });
+
+ if (!response.ok) {
+ const body = await response.text();
+
+ throw new Error(
+ `OpenProcessing request failed: ${response.status} ${response.statusText}\n${body}`
+ );
+ }
+
+ if (response.status === 204) {
+ return undefined as T;
+ }
+
+ const text = await response.text();
+
+ if (!text) {
+ return undefined as T;
+ }
+
+ return JSON.parse(text) as T;
+}
+
+export async function getCuration(): Promise {
+ const { curationId } = getConfig();
+
+ return request(
+ `/curation/${curationId}`
+ );
+}
+
+export async function getCurationSketches(): Promise<
+ OpenProcessingSketch[]
+> {
+ const { curationId } = getConfig();
+
+ return request(
+ `/curation/${curationId}/sketches`
+ );
+}
+
+export async function addSketchToCuration(
+ visualID: number
+): Promise {
+ const { curationId } = getConfig();
+
+ await request(
+ `/curation/${curationId}/sketches/${visualID}`,
+ {
+ method: "POST",
+ }
+ );
+}
+
+export async function removeSketchFromCuration(
+ visualID: number
+): Promise {
+ const { curationId } = getConfig();
+
+ await request(
+ `/curation/${curationId}/sketches/${visualID}`,
+ {
+ method: "DELETE",
+ }
+ );
+}
\ No newline at end of file
diff --git a/src/scripts/sync-openprocessing.ts b/src/scripts/sync-openprocessing.ts
new file mode 100644
index 0000000000..1479687aea
--- /dev/null
+++ b/src/scripts/sync-openprocessing.ts
@@ -0,0 +1,198 @@
+import { readdir, readFile } from "node:fs/promises";
+import { join } from "node:path";
+import yaml from "js-yaml";
+
+import {
+ addAttributionToCode,
+ type RemixEntry,
+} from "../utils/exampleAttribution";
+
+import { getCurationSketches } from "./openprocessing";
+
+interface ExampleFrontmatter {
+ title: string;
+ oneLineDescription: string;
+ remix?: RemixEntry[];
+}
+
+interface PreparedExample {
+ id: string;
+ title: string;
+ description: string;
+ code: string;
+}
+
+const EXAMPLES_DIRECTORY = "src/content/examples/en";
+
+async function findDescriptionFiles(directory: string): Promise {
+ const entries = await readdir(directory, {
+ withFileTypes: true,
+ });
+
+ const files: string[] = [];
+
+ for (const entry of entries) {
+ const fullPath = join(directory, entry.name);
+
+ if (entry.isDirectory()) {
+ files.push(...(await findDescriptionFiles(fullPath)));
+ } else if (entry.name === "description.mdx") {
+ files.push(fullPath);
+ }
+ }
+
+ return files;
+}
+
+function parseFrontmatter(contents: string): ExampleFrontmatter {
+ const match = contents.match(/^---\s*\n([\s\S]*?)\n---/);
+
+ if (!match) {
+ throw new Error("Example does not contain YAML frontmatter.");
+ }
+
+ const data = yaml.load(match[1]);
+
+ if (
+ typeof data !== "object" ||
+ data === null ||
+ !("title" in data) ||
+ !("oneLineDescription" in data)
+ ) {
+ throw new Error("Example frontmatter is missing required fields.");
+ }
+
+ return data as ExampleFrontmatter;
+}
+
+async function prepareExample(
+ descriptionPath: string
+): Promise {
+ const descriptionContents = await readFile(
+ descriptionPath,
+ "utf-8"
+ );
+
+ const data = parseFrontmatter(descriptionContents);
+
+ const codePath = join(
+ descriptionPath.substring(
+ 0,
+ descriptionPath.lastIndexOf("/")
+ ),
+ "code.js"
+ );
+
+ let code = await readFile(codePath, "utf-8");
+
+ // Match the website's existing asset-path behavior.
+ code = code.replaceAll(/\(["']assets/g, "('/assets");
+
+ const attributedCode = addAttributionToCode(
+ code,
+ data.title,
+ data.remix ?? []
+ );
+
+ const id = descriptionPath
+ .replace(`${EXAMPLES_DIRECTORY}/`, "")
+ .replace("/description.mdx", "");
+
+ return {
+ id,
+ title: data.title,
+ description: data.oneLineDescription,
+ code: attributedCode,
+ };
+}
+
+async function getEnglishExamples(): Promise {
+ const descriptionFiles = await findDescriptionFiles(
+ EXAMPLES_DIRECTORY
+ );
+
+ return Promise.all(
+ descriptionFiles.map((file) => prepareExample(file))
+ );
+}
+
+async function main(): Promise {
+ console.log("Loading p5.js examples...");
+
+ const examples = await getEnglishExamples();
+
+ console.log(`Found ${examples.length} English examples.`);
+
+ console.log("Loading OpenProcessing curation...");
+
+ const curationSketches = await getCurationSketches();
+
+ console.log(
+ `Found ${curationSketches.length} sketches in the OpenProcessing curation.`
+ );
+
+ const curationTitles = new Set(
+ curationSketches
+ .map((sketch) => sketch.title)
+ .filter((title): title is string => Boolean(title))
+ );
+
+ const websiteTitles = new Set(
+ examples.map((example) => example.title)
+ );
+
+ const missingExamples = examples.filter(
+ (example) => !curationTitles.has(example.title)
+ );
+
+ const extraSketches = curationSketches.filter(
+ (sketch) =>
+ sketch.title !== undefined &&
+ !websiteTitles.has(sketch.title)
+ );
+
+ console.log("");
+ console.log("Sync summary");
+ console.log("------------");
+ console.log(`Website examples: ${examples.length}`);
+ console.log(`Curation sketches: ${curationSketches.length}`);
+ console.log(`Missing from curation: ${missingExamples.length}`);
+ console.log(`Extra in curation: ${extraSketches.length}`);
+
+ if (missingExamples.length > 0) {
+ console.log("");
+ console.log("Missing examples:");
+
+ for (const example of missingExamples) {
+ console.log(`- ${example.title}`);
+ }
+ }
+
+ if (extraSketches.length > 0) {
+ console.log("");
+ console.log("Extra sketches:");
+
+ for (const sketch of extraSketches) {
+ console.log(
+ `- ${sketch.title ?? "Untitled"} (${sketch.visualID})`
+ );
+ }
+ }
+
+ console.log("");
+ console.log(
+ "Dry run complete. No OpenProcessing sketches were modified."
+ );
+}
+
+main().catch((error: unknown) => {
+ console.error("OpenProcessing sync failed.");
+
+ if (error instanceof Error) {
+ console.error(error.message);
+ } else {
+ console.error(error);
+ }
+
+ process.exitCode = 1;
+});
\ No newline at end of file
diff --git a/src/utils/exampleAttribution.ts b/src/utils/exampleAttribution.ts
new file mode 100644
index 0000000000..cc2da910be
--- /dev/null
+++ b/src/utils/exampleAttribution.ts
@@ -0,0 +1,81 @@
+export interface Attribution {
+ name: string;
+ URL?: string;
+}
+
+export interface RemixEntry {
+ description?: string;
+ attribution?: Attribution[];
+ collectivelyAttributedSince?: number;
+ code?: {
+ label?: string;
+ URL?: string;
+ }[];
+}
+
+/**
+ * Creates a plain-text attribution that can be embedded in an
+ * OpenProcessing sketch.
+ */
+export function generateExampleAttribution(
+ title: string,
+ remixData?: RemixEntry[]
+): string {
+ const lines: string[] = [];
+
+ lines.push(title);
+ lines.push("");
+
+ remixData?.forEach((item) => {
+ if (item.collectivelyAttributedSince || !item.attribution?.length) {
+ return;
+ }
+
+ const description = item.description ?? "Based on";
+
+ const authors = item.attribution
+ .map((author) =>
+ author.URL ? `${author.name} (${author.URL})` : author.name
+ )
+ .join(", ");
+
+ lines.push(`${description}: ${authors}`);
+ });
+
+ const collectiveYear = remixData?.reduce(
+ (year, item) => item.collectivelyAttributedSince ?? year,
+ undefined
+ );
+
+ if (collectiveYear) {
+ lines.push(
+ `From ${collectiveYear} onwards, edited and maintained by p5.js Contributors and Processing Foundation.`
+ );
+ } else {
+ lines.push(
+ "Edited and maintained by p5.js Contributors and Processing Foundation."
+ );
+ }
+
+ lines.push("Licensed under CC BY-NC-SA 4.0.");
+
+ return lines.join("\n");
+}
+
+/**
+ * Adds attribution as JavaScript comments above the sketch code.
+ */
+export function addAttributionToCode(
+ code: string,
+ title: string,
+ remixData?: RemixEntry[]
+): string {
+ const attribution = generateExampleAttribution(title, remixData);
+
+ const comment = attribution
+ .split("\n")
+ .map((line) => `// ${line}`)
+ .join("\n");
+
+ return `${comment}\n\n${code}`;
+}
\ No newline at end of file
diff --git a/test/scripts/openprocessing.test.ts b/test/scripts/openprocessing.test.ts
new file mode 100644
index 0000000000..9394eda9f9
--- /dev/null
+++ b/test/scripts/openprocessing.test.ts
@@ -0,0 +1,39 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { getCurationSketches } from "../../src/scripts/openprocessing";
+
+const originalToken = process.env.OPENPROCESSING_TOKEN;
+const originalCurationId = process.env.OPENPROCESSING_CURATION_ID;
+
+afterEach(() => {
+ if (originalToken === undefined) {
+ delete process.env.OPENPROCESSING_TOKEN;
+ } else {
+ process.env.OPENPROCESSING_TOKEN = originalToken;
+ }
+
+ if (originalCurationId === undefined) {
+ delete process.env.OPENPROCESSING_CURATION_ID;
+ } else {
+ process.env.OPENPROCESSING_CURATION_ID = originalCurationId;
+ }
+});
+
+describe("OpenProcessing configuration", () => {
+ it("requires an API token", async () => {
+ delete process.env.OPENPROCESSING_TOKEN;
+ process.env.OPENPROCESSING_CURATION_ID = "91157";
+
+ await expect(getCurationSketches()).rejects.toThrow(
+ "Missing OPENPROCESSING_TOKEN environment variable."
+ );
+ });
+
+ it("requires a curation ID", async () => {
+ process.env.OPENPROCESSING_TOKEN = "test-token";
+ delete process.env.OPENPROCESSING_CURATION_ID;
+
+ await expect(getCurationSketches()).rejects.toThrow(
+ "Missing OPENPROCESSING_CURATION_ID environment variable."
+ );
+ });
+});
\ No newline at end of file
diff --git a/test/utils/exampleAttribution.test.ts b/test/utils/exampleAttribution.test.ts
new file mode 100644
index 0000000000..2e0f7167a6
--- /dev/null
+++ b/test/utils/exampleAttribution.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from "vitest";
+import {
+ addAttributionToCode,
+ generateExampleAttribution,
+} from "../../src/utils/exampleAttribution";
+
+describe("generateExampleAttribution", () => {
+ it("generates attribution for remix authors", () => {
+ const result = generateExampleAttribution("Kaleidoscope", [
+ {
+ description: "Revised by",
+ attribution: [
+ {
+ name: "Kasey Lichtyler",
+ URL: "https://www.klich.co/",
+ },
+ ],
+ },
+ ]);
+
+ expect(result).toContain("Kaleidoscope");
+ expect(result).toContain(
+ "Revised by: Kasey Lichtyler (https://www.klich.co/)"
+ );
+ expect(result).toContain("p5.js Contributors");
+ expect(result).toContain("CC BY-NC-SA 4.0");
+ });
+
+ it("supports collective attribution years", () => {
+ const result = generateExampleAttribution("Example", [
+ {
+ collectivelyAttributedSince: 2023,
+ },
+ ]);
+
+ expect(result).toContain(
+ "From 2023 onwards, edited and maintained by p5.js Contributors and Processing Foundation."
+ );
+ });
+
+ it("adds attribution as comments above sketch code", () => {
+ const code = `function setup() {
+ createCanvas(400, 400);
+}`;
+
+ const result = addAttributionToCode(code, "Kaleidoscope", [
+ {
+ description: "Revised by",
+ attribution: [
+ {
+ name: "Kasey Lichtyler",
+ },
+ ],
+ },
+ ]);
+
+ expect(result).toContain("// Kaleidoscope");
+ expect(result).toContain("// Revised by: Kasey Lichtyler");
+ expect(result).toContain("// Licensed under CC BY-NC-SA 4.0.");
+ expect(result).toContain("function setup()");
+ });
+});
\ No newline at end of file
From fe4e8c640aaaf6dcb16dcc063f6d56a91ded6a4c Mon Sep 17 00:00:00 2001
From: Yisakor M
Date: Thu, 20 Aug 2026 21:10:21 -0700
Subject: [PATCH 2/3] Address OpenProcessing sync review feedback
---
src/layouts/ExampleLayout.astro | 200 +++++++++++++++--------------
src/scripts/sync-openprocessing.ts | 9 +-
src/utils/exampleAttribution.ts | 116 +++++++++++++----
src/utils/examplePaths.ts | 14 ++
test/utils/examplePaths.test.ts | 28 ++++
5 files changed, 239 insertions(+), 128 deletions(-)
create mode 100644 src/utils/examplePaths.ts
create mode 100644 test/utils/examplePaths.test.ts
diff --git a/src/layouts/ExampleLayout.astro b/src/layouts/ExampleLayout.astro
index 96c11f7d1e..8ea13c17cc 100644
--- a/src/layouts/ExampleLayout.astro
+++ b/src/layouts/ExampleLayout.astro
@@ -1,17 +1,23 @@
---
import type { CollectionEntry } from "astro:content";
import { render } from "astro:content";
+
+import EditableSketch from "@components/EditableSketch/index.astro";
import Head from "@components/Head/index.astro";
+import RelatedItems from "@components/RelatedItems/index.astro";
+
import { getCurrentLocale, getUiTranslator } from "../i18n/utils";
import {
generateJumpToState,
+ getFallbackRemixData,
getRelatedEntriesinCollection,
} from "../pages/_utils";
-import BaseLayout from "./BaseLayout.astro";
-import EditableSketch from "@components/EditableSketch/index.astro";
-import RelatedItems from "@components/RelatedItems/index.astro";
+import {
+ EXAMPLE_ATTRIBUTION,
+ getExampleAttributionData,
+} from "../utils/exampleAttribution";
-import { getFallbackRemixData } from "../pages/_utils";
+import BaseLayout from "./BaseLayout.astro";
interface Props {
example: CollectionEntry<"examples">;
@@ -43,29 +49,17 @@ const relatedReferences =
const { Content } = await render(example);
-// Use the fallback function to retrieve English remix data if the current locale doesn't have any
-let remixData = (await getFallbackRemixData(
+// Use the fallback function to retrieve English remix data if the current
+// locale doesn't have any.
+const remixData = (await getFallbackRemixData(
example.id,
currentLocale,
example.data.remix
)) as typeof example.data.remix;
-// Extract the collective attribution year. If multiple provided, uses last shown.
-const collectivelyAttributedSince = remixData?.reduce(
- (acc: number | null, item) => {
- if (item.collectivelyAttributedSince) {
- return item.collectivelyAttributedSince;
- }
- return acc;
- },
- null
-);
-
-// Boolean value on whether the remix history contains links to code
-const remixHistoryHasCodeLinks = remixData?.some(
- (item) => Array.isArray(item.code) && item.code.length > 0
-);
-
+// Shared attribution rules used by both the website layout and
+// OpenProcessing synchronization.
+const attributionData = getExampleAttributionData(remixData ?? []);
---
+
-

+
+ {example.data.title}:{" "}
+
+ {
+ attributionData.remixes.map((item, i) => (
+
+ {i > 0 && " "}
+ {t("attribution", item.description)}{" "}
+ {item.attribution.map((author, j) => (
+ <>
+ {author.URL ? (
+ {t(author.name)}
+ ) : (
+ t(author.name)
+ )}
+ {j < item.attribution.length - 1 ? ", " : "."}
+ >
+ ))}
+
+ ))
+ }
- {example.data.title}:{" "}
-
- {remixData?.map((item, i) => {
- const parts = [];
-
- // Each remix entry requires at least one attribution
- // If a remix entry contains a collective attribution starting year, it is ignored here
+ {
+ attributionData.collectivelyAttributedSince ? (
+ <>
+ {t(
+ "attribution",
+ `From ${attributionData.collectivelyAttributedSince} onwards, edited and maintained by`
+ )}{" "}
+ >
+ ) : (
+ <>
+ {t("attribution", "Edited and maintained by")}{" "}
+ >
+ )
+ }
- if (!item.collectivelyAttributedSince && item.attribution) {
- parts.push(<>{t("attribution", item.description)}>);
+
+
+ {EXAMPLE_ATTRIBUTION.contributors.name}
+ {" "}
+ {t("attribution", "and")}{" "}
+
+ {EXAMPLE_ATTRIBUTION.foundation.name}
+ .
+ {" "}Licensed under{" "}
+
+ {EXAMPLE_ATTRIBUTION.license.name}
+ .
+
+
- if (item.attribution?.length) {
- parts.push(
- <>
- {" "}
- {item.attribution.map((a, j) => (
+
+ {
+ attributionData.codeLinks.length > 0 ? (
+ <>
+ {t(
+ "attribution",
+ "You can find the code history of these examples here"
+ )}
+ {": "}
+
+ {attributionData.codeLinks.map(
+ (codeItem, i, codeItemsList) => (
<>
- {a.URL ? {t(a.name)} : t(a.name)}
- {
- item.attribution?.length
- ? j < item.attribution.length - 1 ? ", " : "."
- : ""
- }
+ {codeItem.label}
+ {i < codeItemsList.length - 1 ? ", " : ". "}
>
- ))}
- >
- );
- }
+ )
+ )}
+
+ {t(
+ "attribution",
+ "You can suggest improvements by"
+ )}{" "}
+
+ {t(
+ "attribution",
+ "contributing to the current website"
+ )}
+
+ !
+ >
+ ) : (
+ <>>
+ )
}
+
+
- return {i > 0 && " "}{parts};
- })}
-
- {collectivelyAttributedSince ? (
- <>{t("attribution", `From ${collectivelyAttributedSince} onwards, edited and maintained by`)}{" "}>
- ) : (
- <>{t("attribution", "Edited and maintained by")}{" "}>
- )}
-
- p5.js Contributors{" "}
- {t("attribution", "and")}{" "}
- Processing Foundation.
- Licensed under{" "}
- CC BY-NC-SA 4.0.
-
-
-
-
- {remixHistoryHasCodeLinks ? (
- <>
- {t("attribution", "You can find the code history of these examples here")}{": "}
- {remixData
- .map(item => item?.code)
- .flat()
- .filter(codeItem => codeItem && codeItem.URL)
- .map((codeItem, i, codeItemsList) => (
- <>
- {codeItem?.label}{
- i < (codeItemsList?.length ?? 0) - 1 ? ", " : ". "
- }
- >
- ))}
- {t("attribution", "You can suggest improvements by")}{" "}
- {t("attribution", "contributing to the current website")}!
- >
- ) : (
- <>>
- )}
-
-
-
+
+ {example.data.arialabel}
-
- {example.data.arialabel}
-
-
{
relatedReferences.length > 0 ? (
@@ -179,9 +192,10 @@ const remixHistoryHasCodeLinks = remixData?.some(
/>
) : null
}
+
-
+
\ No newline at end of file
diff --git a/src/scripts/sync-openprocessing.ts b/src/scripts/sync-openprocessing.ts
index 1479687aea..f1aa1644ad 100644
--- a/src/scripts/sync-openprocessing.ts
+++ b/src/scripts/sync-openprocessing.ts
@@ -6,6 +6,7 @@ import {
addAttributionToCode,
type RemixEntry,
} from "../utils/exampleAttribution";
+import { getExampleCodePath } from "../utils/examplePaths";
import { getCurationSketches } from "./openprocessing";
@@ -75,13 +76,7 @@ async function prepareExample(
const data = parseFrontmatter(descriptionContents);
- const codePath = join(
- descriptionPath.substring(
- 0,
- descriptionPath.lastIndexOf("/")
- ),
- "code.js"
- );
+ const codePath = getExampleCodePath(descriptionPath);
let code = await readFile(codePath, "utf-8");
diff --git a/src/utils/exampleAttribution.ts b/src/utils/exampleAttribution.ts
index cc2da910be..b036f7c6d6 100644
--- a/src/utils/exampleAttribution.ts
+++ b/src/utils/exampleAttribution.ts
@@ -3,61 +3,118 @@ export interface Attribution {
URL?: string;
}
+export interface RemixCodeLink {
+ label?: string;
+ URL?: string;
+}
+
export interface RemixEntry {
description?: string;
attribution?: Attribution[];
collectivelyAttributedSince?: number;
- code?: {
- label?: string;
- URL?: string;
+ code?: RemixCodeLink[];
+}
+
+export interface ExampleAttributionData {
+ remixes: {
+ description: string;
+ attribution: Attribution[];
}[];
+ collectivelyAttributedSince?: number;
+ codeLinks: RemixCodeLink[];
+}
+
+export const EXAMPLE_ATTRIBUTION = {
+ contributors: {
+ name: "p5.js Contributors",
+ URL: "https://github.com/processing/p5.js?tab=readme-ov-file#contributors",
+ },
+ foundation: {
+ name: "Processing Foundation",
+ URL: "https://processingfoundation.org/people",
+ },
+ license: {
+ name: "CC BY-NC-SA 4.0",
+ URL: "https://creativecommons.org/licenses/by-nc-sa/4.0/",
+ },
+};
+
+/**
+ * Resolves attribution information for an example.
+ *
+ * This function is the shared source of truth for both the website
+ * attribution display and OpenProcessing sketch attribution.
+ */
+export function getExampleAttributionData(
+ remixData: RemixEntry[] = []
+): ExampleAttributionData {
+ const remixes = remixData
+ .filter(
+ (item) =>
+ !item.collectivelyAttributedSince &&
+ Boolean(item.attribution?.length)
+ )
+ .map((item) => ({
+ description: item.description ?? "Based on",
+ attribution: item.attribution ?? [],
+ }));
+
+ const collectivelyAttributedSince = remixData.reduce<
+ number | undefined
+ >(
+ (year, item) =>
+ item.collectivelyAttributedSince ?? year,
+ undefined
+ );
+
+ const codeLinks = remixData
+ .flatMap((item) => item.code ?? [])
+ .filter((item) => Boolean(item.URL));
+
+ return {
+ remixes,
+ collectivelyAttributedSince,
+ codeLinks,
+ };
}
/**
- * Creates a plain-text attribution that can be embedded in an
+ * Creates plain-text attribution that can be embedded in an
* OpenProcessing sketch.
*/
export function generateExampleAttribution(
title: string,
remixData?: RemixEntry[]
): string {
- const lines: string[] = [];
-
- lines.push(title);
- lines.push("");
-
- remixData?.forEach((item) => {
- if (item.collectivelyAttributedSince || !item.attribution?.length) {
- return;
- }
+ const attributionData = getExampleAttributionData(remixData);
- const description = item.description ?? "Based on";
+ const lines: string[] = [title, ""];
- const authors = item.attribution
+ for (const remix of attributionData.remixes) {
+ const authors = remix.attribution
.map((author) =>
- author.URL ? `${author.name} (${author.URL})` : author.name
+ author.URL
+ ? `${author.name} (${author.URL})`
+ : author.name
)
.join(", ");
- lines.push(`${description}: ${authors}`);
- });
-
- const collectiveYear = remixData?.reduce(
- (year, item) => item.collectivelyAttributedSince ?? year,
- undefined
- );
+ lines.push(`${remix.description}: ${authors}`);
+ }
- if (collectiveYear) {
+ if (attributionData.collectivelyAttributedSince) {
lines.push(
- `From ${collectiveYear} onwards, edited and maintained by p5.js Contributors and Processing Foundation.`
+ `From ${attributionData.collectivelyAttributedSince} onwards, edited and maintained by ${EXAMPLE_ATTRIBUTION.contributors.name} and ${EXAMPLE_ATTRIBUTION.foundation.name}.`
);
} else {
lines.push(
- "Edited and maintained by p5.js Contributors and Processing Foundation."
+ `Edited and maintained by ${EXAMPLE_ATTRIBUTION.contributors.name} and ${EXAMPLE_ATTRIBUTION.foundation.name}.`
);
}
- lines.push("Licensed under CC BY-NC-SA 4.0.");
+ lines.push(
+ `Licensed under ${EXAMPLE_ATTRIBUTION.license.name}.`
+ );
return lines.join("\n");
}
@@ -70,7 +127,10 @@ export function addAttributionToCode(
title: string,
remixData?: RemixEntry[]
): string {
- const attribution = generateExampleAttribution(title, remixData);
+ const attribution = generateExampleAttribution(
+ title,
+ remixData
+ );
const comment = attribution
.split("\n")
diff --git a/src/utils/examplePaths.ts b/src/utils/examplePaths.ts
new file mode 100644
index 0000000000..89c35d318b
--- /dev/null
+++ b/src/utils/examplePaths.ts
@@ -0,0 +1,14 @@
+import type path from "node:path";
+import { dirname, join } from "node:path";
+
+type PathFunctions = Pick;
+
+export function getExampleCodePath(
+ descriptionPath: string,
+ pathFunctions: PathFunctions = { dirname, join }
+): string {
+ return pathFunctions.join(
+ pathFunctions.dirname(descriptionPath),
+ "code.js"
+ );
+}
\ No newline at end of file
diff --git a/test/utils/examplePaths.test.ts b/test/utils/examplePaths.test.ts
new file mode 100644
index 0000000000..14f3e670a6
--- /dev/null
+++ b/test/utils/examplePaths.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from "vitest";
+import { posix, win32 } from "node:path";
+
+import { getExampleCodePath } from "../../src/utils/examplePaths";
+
+describe("getExampleCodePath", () => {
+ it("creates a code path for POSIX paths", () => {
+ const descriptionPath =
+ "src/content/examples/en/07_Repetition/03_Kaleidoscope/description.mdx";
+
+ expect(
+ getExampleCodePath(descriptionPath, posix)
+ ).toBe(
+ "src/content/examples/en/07_Repetition/03_Kaleidoscope/code.js"
+ );
+ });
+
+ it("creates a code path for Windows paths", () => {
+ const descriptionPath =
+ "src\\content\\examples\\en\\07_Repetition\\03_Kaleidoscope\\description.mdx";
+
+ expect(
+ getExampleCodePath(descriptionPath, win32)
+ ).toBe(
+ "src\\content\\examples\\en\\07_Repetition\\03_Kaleidoscope\\code.js"
+ );
+ });
+});
\ No newline at end of file
From f72d5f5cce678150213e98fb840b224ee7eaba58 Mon Sep 17 00:00:00 2001
From: Yisakor Mirany
Date: Thu, 20 Aug 2026 21:25:38 -0700
Subject: [PATCH 3/3] Refactor import statement for path module
---
src/utils/examplePaths.ts | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/src/utils/examplePaths.ts b/src/utils/examplePaths.ts
index 89c35d318b..c3aad4daea 100644
--- a/src/utils/examplePaths.ts
+++ b/src/utils/examplePaths.ts
@@ -1,5 +1,4 @@
-import type path from "node:path";
-import { dirname, join } from "node:path";
+import path, { dirname, join } from "node:path";
type PathFunctions = Pick;
@@ -11,4 +10,4 @@ export function getExampleCodePath(
pathFunctions.dirname(descriptionPath),
"code.js"
);
-}
\ No newline at end of file
+}