@goodfellow-cms/registry 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goodfellow-cms/registry",
3
- "version": "0.8.0",
4
- "description": "Goodfellow's block registry: blocks built with shadcn/ui, which sites install from the admin panel.",
3
+ "version": "0.10.0",
4
+ "description": "Goodfellow CMS's block registry: blocks built with shadcn/ui, which sites install from the admin panel.",
5
5
  "keywords": [
6
6
  "goodfellow",
7
7
  "cms",
@@ -13,6 +13,10 @@
13
13
  ],
14
14
  "homepage": "https://goodfellow-cms.github.io/goodfellow-cms/docs/block-registries",
15
15
  "bugs": "https://github.com/goodfellow-cms/goodfellow-cms/issues",
16
+ "funding": {
17
+ "type": "github",
18
+ "url": "https://github.com/sponsors/goodfellow-cms"
19
+ },
16
20
  "license": "MIT",
17
21
  "repository": {
18
22
  "type": "git",
@@ -28,8 +32,8 @@
28
32
  "./package.json": "./package.json"
29
33
  },
30
34
  "devDependencies": {
31
- "@goodfellow-cms/core": "0.8.0",
32
- "@goodfellow-cms/react": "0.8.0",
35
+ "@goodfellow-cms/core": "0.10.0",
36
+ "@goodfellow-cms/react": "0.10.0",
33
37
  "@puckeditor/core": "0.23.0",
34
38
  "@types/react": "^19.2.0",
35
39
  "@types/react-dom": "^19.2.0",
package/r/comments.json CHANGED
@@ -3,12 +3,22 @@
3
3
  "name": "comments",
4
4
  "type": "registry:block",
5
5
  "title": "Comments",
6
- "description": "Comments on the page through Giscus, kept as GitHub Discussions; visitors sign in with GitHub to comment.",
6
+ "description": "Comments on the page through Giscus, kept as GitHub Discussions, where visitors sign in with GitHub to comment, through Disqus, or through the site owner's own Garrul.",
7
7
  "files": [
8
8
  {
9
9
  "path": "blocks/installed/comments/block.tsx",
10
10
  "type": "registry:block",
11
- "content": "import {\n CONSENT_ACCEPT_ATTRIBUTE,\n CONSENT_CODE_ATTRIBUTE,\n CONSENT_PLACEHOLDER_ATTRIBUTE,\n consentSource,\n} from \"@goodfellow-cms/core\";\nimport { classNameField, useParts, useSite, withKeywords, withParts } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig } from \"@puckeditor/core\";\nimport type { ReactNode } from \"react\";\nimport { GiscusFrame } from \"./giscus-frame\";\n\nexport interface CommentsProps {\n heading: string;\n /**\n * Giscus's code from giscus.app, which names the repository and the\n * discussions' category, for comments kept somewhere other than where Site\n * Settings' Comments says; left empty, the block shows those.\n */\n code: string;\n reactions: boolean;\n /** Where the box for writing a comment is: above the comments or under them. */\n input: \"top\" | \"bottom\";\n className: string;\n}\n\n/** Giscus's script, from its own address, which loads its comments in a frame. */\nexport const GISCUS_SCRIPT = \"https://giscus.app/client.js\";\n\n/** What Giscus needs to know where a site's discussions are. */\nexport interface GiscusSettings {\n repo: string;\n repoId: string;\n category: string;\n categoryId: string;\n}\n\n/** The repository and category named in Giscus's code, if it names them as Giscus writes them. */\nexport function giscusSettings(code: string): GiscusSettings | undefined {\n const attribute = (name: string) =>\n new RegExp(`\\\\sdata-${name}\\\\s*=\\\\s*[\"']([^\"'<>]*)[\"']`, \"i\").exec(code)?.[1]?.replaceAll(\"&amp;\", \"&\").trim();\n const repo = attribute(\"repo\");\n const repoId = attribute(\"repo-id\");\n const category = attribute(\"category\");\n const categoryId = attribute(\"category-id\");\n if (!repo || !/^[A-Za-z0-9-]+\\/[A-Za-z0-9._-]+$/.test(repo)) return undefined;\n if (!repoId || !/^[A-Za-z0-9_-]+$/.test(repoId)) return undefined;\n if (!categoryId || !/^[A-Za-z0-9_-]+$/.test(categoryId)) return undefined;\n return { repo, repoId, category: category ?? \"\", categoryId };\n}\n\nconst escapeText = (text: string) =>\n text.replace(/[&<>\"]/g, (c) => ({ \"&\": \"&amp;\", \"<\": \"&lt;\", \">\": \"&gt;\", '\"': \"&quot;\" })[c] as string);\n\nfunction CommentsView({\n heading,\n code,\n reactions,\n input,\n className,\n puck,\n}: CommentsProps & { puck?: { isEditing?: boolean } }) {\n const part = useParts();\n const { settings, adminLink } = useSite();\n const giscus = giscusSettings(code ?? \"\") ?? (code?.trim() ? undefined : settings.comments);\n if (!giscus && !puck?.isEditing) return null;\n // Pages are built on the server: in the browser, it's the editor or a preview, which never load Giscus.\n const onSite = typeof document === \"undefined\" && !puck?.isEditing;\n const frameClass = part(\"gf-comments-frame\", \"w-full\");\n let body: ReactNode;\n if (!giscus) {\n const link = code?.trim() ? undefined : adminLink?.(\"comments-settings\");\n const text = code?.trim()\n ? \"This isn't Giscus's code. On giscus.app, choose the repository and category, then copy the code at the end of the page.\"\n : \"Set up comments in Site settings' Comments to show them here.\";\n body = (\n <p className=\"rounded-lg bg-muted p-6 text-muted-foreground\">\n {link ? (\n <a {...link} className=\"underline underline-offset-2 hover:text-foreground\">\n {text}\n </a>\n ) : (\n text\n )}\n </p>\n );\n } else if (!onSite) {\n body = (\n <p className=\"rounded-lg bg-muted p-6 text-muted-foreground\">\n Comments from {giscus.repo}'s discussions show here on the site.\n </p>\n );\n } else {\n const attributes: Record<string, string> = {\n \"data-repo\": giscus.repo,\n \"data-repo-id\": giscus.repoId,\n \"data-category\": giscus.category,\n \"data-category-id\": giscus.categoryId,\n \"data-mapping\": \"pathname\",\n \"data-strict\": \"1\",\n \"data-reactions-enabled\": reactions === false ? \"0\" : \"1\",\n \"data-emit-metadata\": \"0\",\n \"data-input-position\": input === \"top\" ? \"top\" : \"bottom\",\n \"data-theme\": settings.theme?.darkMode ? \"preferred_color_scheme\" : \"light\",\n \"data-lang\": (settings.language ?? \"en\").split(\"-\")[0] ?? \"en\",\n \"data-loading\": \"lazy\",\n };\n const consent = settings.consent;\n if (consent) {\n // Held back in a template for the consent script, which runs it where it is once the visitor agrees.\n const script = `<script src=\"${GISCUS_SCRIPT}\" crossorigin=\"anonymous\" async${Object.entries(attributes)\n .map(([name, value]) => ` ${name}=\"${escapeText(value)}\"`)\n .join(\"\")}></script>`;\n const words = consent.held.replaceAll(\"{site}\", consentSource(GISCUS_SCRIPT));\n body = (\n <div\n // biome-ignore lint/security/noDangerouslySetInnerHtml: Giscus's script with escaped settings, held back until the visitor agrees\n dangerouslySetInnerHTML={{\n __html: `<div class=\"giscus ${escapeText(frameClass)}\"></div><template ${CONSENT_CODE_ATTRIBUTE}=\"body\">${script}</template><div class=\"gf-consent-placeholder gf-consent-inline\" ${CONSENT_PLACEHOLDER_ATTRIBUTE}=\"\"><p>${escapeText(words)}</p><button type=\"button\" ${CONSENT_ACCEPT_ATTRIBUTE}=\"content\">${escapeText(consent.show)}</button></div>`,\n }}\n />\n );\n } else {\n body = <GiscusFrame script={GISCUS_SCRIPT} attributes={attributes} className={frameClass} />;\n }\n }\n return (\n <section className={part(\"gf-comments\", \"w-full\", className)}>\n {heading && (\n <h2 className={part(\"gf-comments-heading\", \"mb-6 font-heading text-2xl font-semibold\")}>{heading}</h2>\n )}\n {body}\n </section>\n );\n}\n\n/**\n * Comments on the page, kept as GitHub Discussions in a repository of their\n * own, through Giscus: visitors sign in with GitHub to comment. Where they're\n * kept is Site Settings' Comments (`content/site.json`'s `comments`), unless\n * the block is given Giscus's code for somewhere else.\n * Giscus loads only on the built site, never in the editor or previews, and\n * on a site that asks visitors before loading other sites' content, it waits\n * until they agree.\n */\nconst Comments: ComponentConfig<CommentsProps> = withParts(\n {\n label: \"Comments\",\n fields: {\n heading: { type: \"text\", label: \"Heading\", contentEditable: true },\n code: {\n type: \"textarea\",\n label: \"Giscus's code, for comments kept elsewhere than Site settings says (optional)\",\n },\n reactions: {\n type: \"radio\",\n label: \"Reactions to the page\",\n options: [\n { value: true, label: \"Shown\" },\n { value: false, label: \"Hidden\" },\n ],\n },\n input: {\n type: \"radio\",\n label: \"Box for writing a comment\",\n options: [\n { value: \"bottom\", label: \"Under the comments\" },\n { value: \"top\", label: \"Above the comments\" },\n ],\n },\n className: classNameField,\n },\n defaultProps: { heading: \"Comments\", code: \"\", reactions: true, input: \"bottom\", className: \"\" },\n render: (props) => <CommentsView {...props} />,\n },\n {\n \"gf-comments\": \"Comments\",\n \"gf-comments-heading\": \"Heading\",\n \"gf-comments-frame\": \"Comments' frame\",\n },\n);\n\nexport default withKeywords(Comments, [\"comments\", \"discussion\", \"giscus\", \"replies\", \"feedback\", \"github\"]);\n"
11
+ "content": "import {\n CONSENT_ACCEPT_ATTRIBUTE,\n CONSENT_CODE_ATTRIBUTE,\n CONSENT_PLACEHOLDER_ATTRIBUTE,\n type CommentsPlace,\n consentSource,\n disqusEmbedUrl,\n disqusShortname,\n garrulAddress,\n garrulEmbedUrl,\n garrulSlug,\n isDisqus,\n isGarrul,\n type SiteCommentsPlace,\n type SiteSettings,\n} from \"@goodfellow-cms/core\";\nimport { classNameField, useParts, useSite, withKeywords, withParts } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig } from \"@puckeditor/core\";\nimport type { ReactNode } from \"react\";\nimport { DisqusFrame, type DisqusPage } from \"./disqus-frame\";\nimport { GarrulFrame } from \"./garrul-frame\";\nimport { GiscusFrame } from \"./giscus-frame\";\n\nexport interface CommentsProps {\n heading: string;\n /**\n * Giscus's code from giscus.app, which names the repository and the\n * discussions' category, Disqus's code or shortname, or Garrul's code or\n * address, for comments kept\n * somewhere other than where Site Settings' Comments says; left empty, the\n * block shows those.\n */\n code: string;\n /** Giscus's reactions to the page, offered only for Giscus: Disqus and Garrul have their own settings. */\n reactions: boolean;\n /** Where Giscus's box for writing a comment is: above the comments or under them. Offered only for Giscus. */\n input: \"top\" | \"bottom\";\n className: string;\n}\n\n/** Giscus's script, from its own address, which loads its comments in a frame. */\nexport const GISCUS_SCRIPT = \"https://giscus.app/client.js\";\n\n/** What Giscus needs to know where a site's discussions are. */\nexport interface GiscusSettings {\n repo: string;\n repoId: string;\n category: string;\n categoryId: string;\n}\n\n/** The repository and category named in Giscus's code, if it names them as Giscus writes them. */\nexport function giscusSettings(code: string): GiscusSettings | undefined {\n const attribute = (name: string) =>\n new RegExp(`\\\\sdata-${name}\\\\s*=\\\\s*[\"']([^\"'<>]*)[\"']`, \"i\").exec(code)?.[1]?.replaceAll(\"&amp;\", \"&\").trim();\n const repo = attribute(\"repo\");\n const repoId = attribute(\"repo-id\");\n const category = attribute(\"category\");\n const categoryId = attribute(\"category-id\");\n if (!repo || !/^[A-Za-z0-9-]+\\/[A-Za-z0-9._-]+$/.test(repo)) return undefined;\n if (!repoId || !/^[A-Za-z0-9_-]+$/.test(repoId)) return undefined;\n if (!categoryId || !/^[A-Za-z0-9_-]+$/.test(categoryId)) return undefined;\n return { repo, repoId, category: category ?? \"\", categoryId };\n}\n\n/**\n * What `DisqusFrame` does, as a script of its own, for a site that asks\n * visitors first: held back in a template until they agree, then run where it\n * is by the consent script. `<` is escaped, so nothing in it can end it.\n */\nexport function disqusLoader({ script, identifier, url }: DisqusPage): string {\n const json = (value: unknown) => JSON.stringify(value).replace(/</g, \"\\\\u003c\");\n return `(function(){var w=window,c=function(){this.page.identifier=${identifier ? json(identifier) : \"location.pathname\"};this.page.url=${url ? json(url) : \"location.href\"}};if(w.DISQUS){w.DISQUS.reset({reload:true,config:c});return}w.disqus_config=c;var s=document.createElement(\"script\");s.src=${json(script)};s.async=true;s.setAttribute(\"data-timestamp\",String(Date.now()));document.head.appendChild(s)})();`;\n}\n\n/**\n * Names Garrul's thread by the browser's address, as `garrulSlug()` does,\n * where the block can't know the page (in a Next.js layout), for a site that\n * asks visitors first.\n */\nexport function garrulSlugScript(): string {\n return `(function(){var e=document.getElementById(\"garrul\");if(e&&!e.dataset.slug)e.dataset.slug=location.pathname.replace(/[^a-zA-Z0-9_\\\\-./]/g,\"-\").slice(0,200)||\"/\"})();`;\n}\n\n/** The page's full address, where the site has a Site address. */\nfunction pageUrl(siteUrl: string | undefined, path: string): string | undefined {\n if (!siteUrl) return undefined;\n return `${siteUrl.replace(/\\/+$/, \"\")}${path === \"/\" ? \"/\" : path}`;\n}\n\n/**\n * Where the block's comments are: what its code names, Giscus's, Disqus's or\n * Garrul's, else Site settings' place. Code that's none of theirs names\n * nothing, so a mistake shows in the editor rather than other comments.\n */\nexport function commentsPlace(code: string | undefined, settings: Pick<SiteSettings, \"comments\">) {\n const pasted = code?.trim() ?? \"\";\n if (!pasted) return settings.comments;\n const giscus = giscusSettings(pasted);\n if (giscus) return giscus;\n const disqus = disqusShortname(pasted);\n if (disqus) return { disqus };\n const garrul = garrulAddress(pasted);\n return garrul ? { garrul } : undefined;\n}\n\n/** Whether the comments are Giscus's, whose reactions and box for writing the block's settings choose. */\nfunction isGiscus(place: SiteCommentsPlace | undefined): place is CommentsPlace {\n return place !== undefined && !isDisqus(place) && !isGarrul(place);\n}\n\nconst escapeText = (text: string) =>\n text.replace(/[&<>\"]/g, (c) => ({ \"&\": \"&amp;\", \"<\": \"&lt;\", \">\": \"&gt;\", '\"': \"&quot;\" })[c] as string);\n\nfunction CommentsView({\n heading,\n code,\n reactions,\n input,\n className,\n puck,\n}: CommentsProps & { puck?: { isEditing?: boolean } }) {\n const part = useParts();\n const { settings, adminLink, path, pages, entry } = useSite();\n const pasted = code?.trim() ?? \"\";\n const place: SiteCommentsPlace | undefined = commentsPlace(code, settings);\n if (!place && !puck?.isEditing) return null;\n // Pages are built on the server: in the browser, it's the editor or a preview, which never load comments.\n const onSite = typeof document === \"undefined\" && !puck?.isEditing;\n const frameClass = part(\"gf-comments-frame\", \"w-full\");\n const consent = settings.consent;\n /** Another site's script, held back until the visitor agrees, beside the placeholder that asks. */\n const held = (frame: string, script: string, from: string) => (\n <div\n // biome-ignore lint/security/noDangerouslySetInnerHtml: the service's script with escaped settings, held back until the visitor agrees\n dangerouslySetInnerHTML={{\n __html: `${frame}<template ${CONSENT_CODE_ATTRIBUTE}=\"body\">${script}</template><div class=\"gf-consent-placeholder gf-consent-inline\" ${CONSENT_PLACEHOLDER_ATTRIBUTE}=\"\"><p>${escapeText((consent?.held ?? \"\").replaceAll(\"{site}\", consentSource(from)))}</p><button type=\"button\" ${CONSENT_ACCEPT_ATTRIBUTE}=\"content\">${escapeText(consent?.show ?? \"\")}</button></div>`,\n }}\n />\n );\n let body: ReactNode;\n if (!place) {\n const link = pasted ? undefined : adminLink?.(\"comments-settings\");\n const text = pasted\n ? \"This isn't Giscus's, Disqus's or Garrul's code. On giscus.app, choose the repository and category, then copy the code at the end of the page; or paste Disqus's code or its site's shortname, or Garrul's code or its address.\"\n : \"Set up comments in Site settings' Comments to show them here.\";\n body = (\n <p className=\"rounded-lg bg-muted p-6 text-muted-foreground\">\n {link ? (\n <a {...link} className=\"underline underline-offset-2 hover:text-foreground\">\n {text}\n </a>\n ) : (\n text\n )}\n </p>\n );\n } else if (!onSite) {\n body = (\n <p className=\"rounded-lg bg-muted p-6 text-muted-foreground\">\n {isDisqus(place)\n ? `Comments from Disqus, for its site ${place.disqus}, show here on the site.`\n : isGarrul(place)\n ? `Comments from Garrul, at ${place.garrul}, show here on the site.`\n : `Comments from ${place.repo}'s discussions show here on the site.`}\n </p>\n );\n } else if (isDisqus(place)) {\n const page: DisqusPage = {\n script: disqusEmbedUrl(place.disqus),\n identifier: path || undefined,\n url: path ? pageUrl(settings.url, path) : undefined,\n };\n body = consent ? (\n held(\n `<div id=\"disqus_thread\" class=\"${escapeText(frameClass)}\"></div>`,\n `<script>${disqusLoader(page)}</script>`,\n \"https://disqus.com\",\n )\n ) : (\n <DisqusFrame {...page} className={frameClass} />\n );\n } else if (isGarrul(place)) {\n const script = garrulEmbedUrl(place.garrul);\n const title =\n (entry?.content.fields.title as string | undefined) || pages?.find((page) => page.path === path)?.title;\n const url = path ? pageUrl(settings.url, path) : undefined;\n // In a Next.js layout, where the page isn't known, Garrul names the thread by the browser's address.\n const attributes: Record<string, string> = {\n \"data-api\": place.garrul,\n ...(path ? { \"data-slug\": garrulSlug(path) } : {}),\n ...(title ? { \"data-title\": title } : {}),\n ...(url ? { \"data-url\": url } : {}),\n \"data-lang\": (settings.language ?? \"en\").split(\"-\")[0] ?? \"en\",\n ...(settings.theme?.darkMode ? {} : { \"data-theme\": \"light\" }),\n };\n if (!path) attributes[\"data-slug\"] = \"\";\n if (consent) {\n const element = `<div id=\"garrul\" class=\"${escapeText(frameClass)}\"${Object.entries(attributes)\n .map(([name, value]) => ` ${name}=\"${escapeText(value)}\"`)\n .join(\"\")}></div>`;\n // Fills in the thread's name from the browser's address where the page isn't known, then loads Garrul.\n const name = path ? \"\" : `<script>${garrulSlugScript()}</script>`;\n body = held(element, `${name}<script src=\"${escapeText(script)}\" async></script>`, place.garrul);\n } else {\n body = <GarrulFrame script={script} attributes={attributes} className={frameClass} />;\n }\n } else {\n const attributes: Record<string, string> = {\n \"data-repo\": place.repo,\n \"data-repo-id\": place.repoId,\n \"data-category\": place.category,\n \"data-category-id\": place.categoryId,\n \"data-mapping\": \"pathname\",\n \"data-strict\": \"1\",\n \"data-reactions-enabled\": reactions === false ? \"0\" : \"1\",\n \"data-emit-metadata\": \"0\",\n \"data-input-position\": input === \"top\" ? \"top\" : \"bottom\",\n \"data-theme\": settings.theme?.darkMode ? \"preferred_color_scheme\" : \"light\",\n \"data-lang\": (settings.language ?? \"en\").split(\"-\")[0] ?? \"en\",\n \"data-loading\": \"lazy\",\n };\n if (consent) {\n // Held back in a template for the consent script, which runs it where it is once the visitor agrees.\n const script = `<script src=\"${GISCUS_SCRIPT}\" crossorigin=\"anonymous\" async${Object.entries(attributes)\n .map(([name, value]) => ` ${name}=\"${escapeText(value)}\"`)\n .join(\"\")}></script>`;\n body = held(`<div class=\"giscus ${escapeText(frameClass)}\"></div>`, script, GISCUS_SCRIPT);\n } else {\n body = <GiscusFrame script={GISCUS_SCRIPT} attributes={attributes} className={frameClass} />;\n }\n }\n return (\n <section className={part(\"gf-comments\", \"w-full\", className)}>\n {heading && (\n <h2 className={part(\"gf-comments-heading\", \"mb-6 font-heading text-2xl font-semibold\")}>{heading}</h2>\n )}\n {body}\n </section>\n );\n}\n\n/**\n * Comments on the page, kept as GitHub Discussions in a repository of their\n * own, through Giscus, where visitors sign in with GitHub to comment, by\n * Disqus, or by Garrul, which the site's owner runs on Cloudflare. Where they're kept is Site Settings' Comments (`content/site.json`'s\n * `comments`), unless the block is given Giscus's, Disqus's or Garrul's code\n * for somewhere else.\n * Either loads only on the built site, never in the editor or previews, and\n * on a site that asks visitors before loading other sites' content, it waits\n * until they agree.\n */\nconst Comments: ComponentConfig<CommentsProps> = withParts(\n {\n label: \"Comments\",\n fields: {\n heading: { type: \"text\", label: \"Heading\", contentEditable: true },\n code: {\n type: \"textarea\",\n label: \"Giscus's, Disqus's or Garrul's code, for comments kept elsewhere than Site settings says (optional)\",\n },\n reactions: {\n type: \"radio\",\n label: \"Reactions to the page\",\n options: [\n { value: true, label: \"Shown\" },\n { value: false, label: \"Hidden\" },\n ],\n },\n input: {\n type: \"radio\",\n label: \"Box for writing a comment\",\n options: [\n { value: \"bottom\", label: \"Under the comments\" },\n { value: \"top\", label: \"Above the comments\" },\n ],\n },\n className: classNameField,\n },\n defaultProps: { heading: \"Comments\", code: \"\", reactions: true, input: \"bottom\", className: \"\" },\n // Reactions and the box for writing are Giscus's settings; Disqus and Garrul have their own, on their own pages.\n resolveFields: (data, { fields, metadata }) => {\n const settings = (metadata?.site as SiteSettings | undefined) ?? {};\n if (isGiscus(commentsPlace(data.props.code, settings))) return fields;\n const { reactions: _reactions, input: _input, ...others } = fields;\n return others as typeof fields;\n },\n render: (props) => <CommentsView {...props} />,\n },\n {\n \"gf-comments\": \"Comments\",\n \"gf-comments-heading\": \"Heading\",\n \"gf-comments-frame\": \"Comments' frame\",\n },\n);\n\nexport default withKeywords(Comments, [\n \"comments\",\n \"discussion\",\n \"giscus\",\n \"disqus\",\n \"garrul\",\n \"replies\",\n \"feedback\",\n \"github\",\n]);\n"
12
+ },
13
+ {
14
+ "path": "blocks/installed/comments/disqus-frame.tsx",
15
+ "type": "registry:component",
16
+ "content": "\"use client\";\n\nimport { useEffect } from \"react\";\n\n/** Runs with Preact as well as React, so sites whose Client Components all do load Preact instead (see Islands). */\nexport const worksWithPreact = true;\n\n/** What Disqus's script reads from the page it's on. */\nexport interface DisqusPage {\n /** Disqus's script for the site, `https://<shortname>.disqus.com/embed.js`. */\n script: string;\n /**\n * What Disqus knows the page's comments by: its address on the site, without\n * the base path; where the block can't know it (in a Next.js layout), the\n * address the browser has.\n */\n identifier?: string;\n /** The page's full address, from the Site address, or else where the browser has it. */\n url?: string;\n}\n\ninterface DisqusWindow {\n DISQUS?: { reset(options: { reload: boolean; config: () => void }): void };\n disqus_config?: () => void;\n}\n\n/**\n * Disqus's comments, loaded once the page has: its script reads\n * `disqus_config` and puts the comments in `#disqus_thread`, which it finds by\n * its id. After moving to another page on a Next.js site, which keeps the same\n * document and so Disqus's script, Disqus is told the page changed instead.\n */\nexport function DisqusFrame({ script, identifier, url, className }: DisqusPage & { className: string }) {\n useEffect(() => {\n const page = window as unknown as DisqusWindow;\n const config = function (this: { page: { identifier: string; url: string } }) {\n this.page.identifier = identifier ?? location.pathname;\n this.page.url = url ?? location.href;\n };\n if (page.DISQUS) {\n page.DISQUS.reset({ reload: true, config });\n return;\n }\n page.disqus_config = config;\n const element = document.createElement(\"script\");\n element.src = script;\n element.async = true;\n element.setAttribute(\"data-timestamp\", String(Date.now()));\n document.head.appendChild(element);\n }, [script, identifier, url]);\n return <div id=\"disqus_thread\" className={className} />;\n}\n"
17
+ },
18
+ {
19
+ "path": "blocks/installed/comments/garrul-frame.tsx",
20
+ "type": "registry:component",
21
+ "content": "\"use client\";\n\nimport { garrulSlug } from \"@goodfellow-cms/core\";\nimport { useEffect, useRef } from \"react\";\n\n/** Runs with Preact as well as React, so sites whose Client Components all do load Preact instead (see Islands). */\nexport const worksWithPreact = true;\n\n/** The elements Garrul's script has been loaded for, which it can show comments in only once. */\nconst started = new WeakSet<Element>();\n\n/**\n * Garrul's comments, loaded once the page has: its script reads its settings\n * from `#garrul`'s attributes, which it finds by its id, and puts the comments\n * there. Its script runs again whenever it's added, so after moving to another\n * page on a Next.js site, which keeps the same document, adding it again shows\n * the new page's. Where the block can't know the page (in a Next.js layout),\n * the thread is named by the browser's address.\n */\nexport function GarrulFrame({\n script,\n attributes,\n className,\n}: {\n script: string;\n attributes: Record<string, string>;\n className: string;\n}) {\n const box = useRef<HTMLDivElement>(null);\n useEffect(() => {\n const host = box.current;\n if (!host || started.has(host)) return;\n started.add(host);\n if (!host.dataset.slug) host.dataset.slug = garrulSlug(location.pathname);\n const element = document.createElement(\"script\");\n element.src = script;\n element.async = true;\n document.head.appendChild(element);\n }, [script]);\n return <div ref={box} id=\"garrul\" className={className} {...attributes} />;\n}\n"
12
22
  },
13
23
  {
14
24
  "path": "blocks/installed/comments/giscus-frame.tsx",
@@ -19,7 +29,7 @@
19
29
  "meta": {
20
30
  "goodfellow": {
21
31
  "category": "Interactive",
22
- "version": "0.8.0"
32
+ "version": "0.10.0"
23
33
  }
24
34
  },
25
35
  "dependencies": []
package/r/countdown.json CHANGED
@@ -19,7 +19,7 @@
19
19
  "meta": {
20
20
  "goodfellow": {
21
21
  "category": "Interactive",
22
- "version": "0.8.0"
22
+ "version": "0.10.0"
23
23
  }
24
24
  },
25
25
  "dependencies": []
@@ -8,7 +8,7 @@
8
8
  {
9
9
  "path": "blocks/installed/custom-html/block.tsx",
10
10
  "type": "registry:block",
11
- "content": "import {\n CONSENT_ACCEPT_ATTRIBUTE,\n CONSENT_CODE_ATTRIBUTE,\n CONSENT_PLACEHOLDER_ATTRIBUTE,\n type ConsentSettings,\n consentSource,\n endsHeldTemplate,\n} from \"@goodfellow-cms/core\";\nimport { classNameField, sanitizeHtml, useParts, useSite, withParts } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig } from \"@puckeditor/core\";\nimport { HtmlPreview } from \"./html-preview\";\nimport { HtmlWithScripts } from \"./html-scripts\";\n\nexport interface CustomHtmlProps {\n html: string;\n sanitize: boolean;\n className: string;\n}\n\n/** HTML that loads or runs something, which a site that asks visitors first holds back. */\nconst LOADS = /<(script|iframe|object|embed|link)[\\s>/]/i;\n\nconst escapeText = (text: string) =>\n text.replace(/[&<>\"]/g, (c) => ({ \"&\": \"&amp;\", \"<\": \"&lt;\", \">\": \"&gt;\", '\"': \"&quot;\" })[c] as string);\n\n/**\n * HTML used as written, held back in a template for the consent script, with\n * a note and a button in its place, which the script hides once the visitor\n * agrees. It's all one element's HTML, so Next.js's React never looks inside\n * it, and leaves what the script adds there alone.\n */\nfunction heldHtml(html: string, consent: ConsentSettings): string {\n const src = /\\ssrc\\s*=\\s*[\"']?((?:https?:)?\\/\\/[^\"'\\s>]+)/i.exec(html)?.[1];\n const words = src\n ? consent.held.replaceAll(\"{site}\", consentSource(src.startsWith(\"//\") ? `https:${src}` : src))\n : consent.message;\n return `<template ${CONSENT_CODE_ATTRIBUTE}=\"body\">${html}</template><div class=\"gf-consent-placeholder gf-consent-inline\" ${CONSENT_PLACEHOLDER_ATTRIBUTE}=\"\"><p>${escapeText(words)}</p><button type=\"button\" ${CONSENT_ACCEPT_ATTRIBUTE}=\"content\">${escapeText(consent.show)}</button></div>`;\n}\n\n/**\n * HTML written by hand, such as a widget's embed code. It's made safe by\n * default: scripts, styles, frames, forms and inline styles are left out. Used\n * as it's written, it runs on the site, but in the editor it's only shown,\n * inside a sandboxed frame, where it can't reach the admin panel, and its\n * scripts don't run. On the site they run whenever its page is shown, also\n * after moving to it on a Next.js site. On a site that asks visitors before\n * loading other sites' content, HTML that loads or runs anything waits until\n * they agree, as other sites' players do.\n */\nconst CustomHtml: ComponentConfig<CustomHtmlProps> = withParts(\n {\n label: \"Custom HTML\",\n fields: {\n html: { type: \"textarea\", label: \"HTML\" },\n sanitize: {\n type: \"radio\",\n label: \"Make it safe\",\n // HTML used as written runs on the site: on full-stack sites, only owners may choose it.\n metadata: { owners: true },\n options: [\n { label: \"Yes: leave out scripts, styles and frames\", value: true },\n { label: \"No: use it exactly as written\", value: false },\n ],\n },\n className: classNameField,\n },\n defaultProps: { html: \"<p>Your HTML goes here.</p>\", sanitize: true, className: \"\" },\n render: ({ html, sanitize, className: chosen, puck }) => {\n const part = useParts();\n const { settings } = useSite();\n const className = part(\"gf-custom-html\", undefined, chosen);\n if (!sanitize && puck.isEditing) return <HtmlPreview html={html} className={className} />;\n // Where pages are built: the admin panel's previews render in the browser and show it at once.\n const consent = typeof document === \"undefined\" ? settings.consent : undefined;\n if (!sanitize && consent && LOADS.test(html)) {\n // HTML that would end the template early would run before the visitor agrees, so it's made safe instead.\n if (endsHeldTemplate(html)) {\n // biome-ignore lint/security/noDangerouslySetInnerHtml: sanitized\n return <div className={className} dangerouslySetInnerHTML={{ __html: sanitizeHtml(html) }} />;\n }\n return (\n <div\n className={className || undefined}\n // biome-ignore lint/security/noDangerouslySetInnerHtml: the editor chose to use this HTML as written; it waits for the visitor's agreement\n dangerouslySetInnerHTML={{ __html: heldHtml(html, consent) }}\n />\n );\n }\n // Only HTML with scripts needs the browser's help, which loads React on the page.\n if (!sanitize && /<script[\\s>]/i.test(html)) return <HtmlWithScripts html={html} className={className} />;\n return (\n <div\n className={className}\n // biome-ignore lint/security/noDangerouslySetInnerHtml: sanitized unless the editor chose to use it as written\n dangerouslySetInnerHTML={{ __html: sanitize ? sanitizeHtml(html) : html }}\n />\n );\n },\n },\n {\n \"gf-custom-html\": \"Custom HTML\",\n },\n);\n\nexport default CustomHtml;\n"
11
+ "content": "import {\n CONSENT_ACCEPT_ATTRIBUTE,\n CONSENT_CODE_ATTRIBUTE,\n CONSENT_PLACEHOLDER_ATTRIBUTE,\n type ConsentSettings,\n consentSource,\n endsHeldTemplate,\n} from \"@goodfellow-cms/core\";\nimport { classNameField, sanitizeHtml, useParts, useSite, withParts } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig } from \"@puckeditor/core\";\nimport { HtmlPreview } from \"./html-preview\";\nimport { HtmlWithScripts } from \"./html-scripts\";\n\nexport interface CustomHtmlProps {\n html: string;\n sanitize: boolean;\n className: string;\n}\n\n/** HTML that loads or runs something, which a site that asks visitors first holds back. */\nconst LOADS = /<(script|iframe|object|embed|link)[\\s>/]/i;\n\nconst escapeText = (text: string) =>\n text.replace(/[&<>\"]/g, (c) => ({ \"&\": \"&amp;\", \"<\": \"&lt;\", \">\": \"&gt;\", '\"': \"&quot;\" })[c] as string);\n\n/**\n * HTML used as written, held back in a template for the consent script, with\n * a note and a button in its place, which the script hides once the visitor\n * agrees. It's all one element's HTML, so Next.js's React never looks inside\n * it, and leaves what the script adds there alone.\n */\nfunction heldHtml(html: string, consent: ConsentSettings): string {\n const src = /\\ssrc\\s*=\\s*[\"']?((?:https?:)?\\/\\/[^\"'\\s>]+)/i.exec(html)?.[1];\n const words = src\n ? consent.held.replaceAll(\"{site}\", consentSource(src.startsWith(\"//\") ? `https:${src}` : src))\n : consent.message;\n return `<template ${CONSENT_CODE_ATTRIBUTE}=\"body\">${html}</template><div class=\"gf-consent-placeholder gf-consent-inline\" ${CONSENT_PLACEHOLDER_ATTRIBUTE}=\"\"><p>${escapeText(words)}</p><button type=\"button\" ${CONSENT_ACCEPT_ATTRIBUTE}=\"content\">${escapeText(consent.show)}</button></div>`;\n}\n\n/**\n * HTML written by hand, such as a widget's embed code. It's made safe by\n * default: scripts, styles, frames, forms and inline styles are left out. Used\n * as it's written, it runs on the site, but in the editor it's only shown,\n * inside a sandboxed frame, where it can't reach the admin panel, and its\n * scripts don't run. On the site they run whenever its page is shown, also\n * after moving to it on a Next.js site. On a site that asks visitors before\n * loading other sites' content, HTML that loads or runs anything waits until\n * they agree, as other sites' players do.\n */\nconst CustomHtml: ComponentConfig<CustomHtmlProps> = withParts(\n {\n label: \"Custom HTML\",\n fields: {\n // HTML, so an item's values filled into a collection's template are escaped here, as in rich text.\n html: { type: \"textarea\", label: \"HTML\", metadata: { html: true } },\n sanitize: {\n type: \"radio\",\n label: \"Make it safe\",\n // HTML used as written runs on the site: on full-stack sites, only owners may choose it.\n metadata: { owners: true },\n options: [\n { label: \"Yes: leave out scripts, styles and frames\", value: true },\n { label: \"No: use it exactly as written\", value: false },\n ],\n },\n className: classNameField,\n },\n defaultProps: { html: \"<p>Your HTML goes here.</p>\", sanitize: true, className: \"\" },\n render: ({ html, sanitize, className: chosen, puck }) => {\n const part = useParts();\n const { settings } = useSite();\n const className = part(\"gf-custom-html\", undefined, chosen);\n if (!sanitize && puck.isEditing) return <HtmlPreview html={html} className={className} />;\n // Where pages are built: the admin panel's previews render in the browser and show it at once.\n const consent = typeof document === \"undefined\" ? settings.consent : undefined;\n if (!sanitize && consent && LOADS.test(html)) {\n // HTML that would end the template early would run before the visitor agrees, so it's made safe instead.\n if (endsHeldTemplate(html)) {\n // biome-ignore lint/security/noDangerouslySetInnerHtml: sanitized\n return <div className={className} dangerouslySetInnerHTML={{ __html: sanitizeHtml(html) }} />;\n }\n return (\n <div\n className={className || undefined}\n // biome-ignore lint/security/noDangerouslySetInnerHtml: the editor chose to use this HTML as written; it waits for the visitor's agreement\n dangerouslySetInnerHTML={{ __html: heldHtml(html, consent) }}\n />\n );\n }\n // Only HTML with scripts needs the browser's help, which loads React on the page.\n if (!sanitize && /<script[\\s>]/i.test(html)) return <HtmlWithScripts html={html} className={className} />;\n return (\n <div\n className={className}\n // biome-ignore lint/security/noDangerouslySetInnerHtml: sanitized unless the editor chose to use it as written\n dangerouslySetInnerHTML={{ __html: sanitize ? sanitizeHtml(html) : html }}\n />\n );\n },\n },\n {\n \"gf-custom-html\": \"Custom HTML\",\n },\n);\n\nexport default CustomHtml;\n"
12
12
  },
13
13
  {
14
14
  "path": "blocks/installed/custom-html/html-preview.tsx",
@@ -24,7 +24,7 @@
24
24
  "meta": {
25
25
  "goodfellow": {
26
26
  "category": "Advanced",
27
- "version": "0.8.0"
27
+ "version": "0.10.0"
28
28
  }
29
29
  },
30
30
  "dependencies": []
package/r/feed-list.json CHANGED
@@ -8,13 +8,13 @@
8
8
  {
9
9
  "path": "blocks/installed/feed-list/block.tsx",
10
10
  "type": "registry:block",
11
- "content": "import { addDays, changesOn, todayIn } from \"@goodfellow-cms/core\";\nimport { classNameField, SiteLink, useParts, withKeywords, withParts } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig } from \"@puckeditor/core\";\n\nexport interface FeedItem {\n title: string;\n link: string;\n date: string;\n}\n\nexport interface FeedListProps {\n heading: string;\n feed: string;\n limit: number;\n showDates: boolean;\n moreLabel: string;\n moreHref: string;\n emptyText: string;\n className: string;\n /** Read from the feed when the site is built. */\n items?: FeedItem[];\n}\n\n/** How long a build reuses a feed it has read. */\nconst FEED_CACHE_MS = 600_000;\nconst FEED_LIMIT = 5_242_880;\nconst feedCache = new Map<string, { at: number; text: Promise<string> }>();\n\n/** Fetches an RSS or Atom feed from an `https://` address, once every few minutes. */\nasync function fetchFeed(address: string): Promise<string> {\n const url = address.trim();\n if (!/^https?:\\/\\//i.test(url)) throw new Error(\"The feed's address must start with https://.\");\n const cached = feedCache.get(url);\n if (cached && Date.now() - cached.at < FEED_CACHE_MS) return cached.text;\n const text = (async () => {\n const response = await fetch(url, {\n headers: { accept: \"application/rss+xml, application/atom+xml, application/xml, text/xml\" },\n signal: AbortSignal.timeout(20_000),\n });\n if (!response.ok) throw new Error(`The feed's address answered ${response.status}.`);\n const body = await response.text();\n if (body.length > FEED_LIMIT) throw new Error(\"The feed is too large to read.\");\n return body;\n })();\n feedCache.set(url, { at: Date.now(), text });\n text.catch(() => feedCache.delete(url));\n return text;\n}\n\nconst entities: Record<string, string> = { amp: \"&\", lt: \"<\", gt: \">\", quot: '\"', apos: \"'\", nbsp: \" \" };\n\n/** A tag's text, without CDATA markers, tags or entities. */\nfunction decode(text: string): string {\n return text\n .replace(/<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>/g, \"$1\")\n .replace(/<[^>]*>/g, \"\")\n .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16)))\n .replace(/&#(\\d+);/g, (_, code) => String.fromCodePoint(Number(code)))\n .replace(/&([a-z]+);/gi, (match, name) => entities[name.toLowerCase()] ?? match)\n .trim();\n}\n\nfunction tag(xml: string, name: string): string {\n const match = xml.match(new RegExp(`<${name}(?:\\\\s[^>]*)?>([\\\\s\\\\S]*?)</${name}>`, \"i\"));\n return decode(match?.[1] ?? \"\");\n}\n\n/** An Atom entry's link: its `alternate` one, or the first. */\nfunction atomLink(xml: string): string {\n const links = [...xml.matchAll(/<link\\b([^>]*)\\/?>/gi)].map((match) => match[1] ?? \"\");\n const chosen = links.find((attrs) => !/rel=[\"'](?!alternate)/i.test(attrs)) ?? links[0] ?? \"\";\n return chosen.match(/href=[\"']([^\"']+)[\"']/i)?.[1] ?? \"\";\n}\n\n/** The titles, links and dates of an RSS or Atom feed's items. Their text and descriptions are left out. */\nexport function readFeedItems(xml: string): FeedItem[] {\n const rss = [...xml.matchAll(/<item\\b[^>]*>([\\s\\S]*?)<\\/item>/gi)].map(([, item = \"\"]) => ({\n title: tag(item, \"title\"),\n link: tag(item, \"link\") || tag(item, \"guid\"),\n date: tag(item, \"pubDate\") || tag(item, \"dc:date\"),\n }));\n const items = rss.length\n ? rss\n : [...xml.matchAll(/<entry\\b[^>]*>([\\s\\S]*?)<\\/entry>/gi)].map(([, entry = \"\"]) => ({\n title: tag(entry, \"title\"),\n link: atomLink(entry),\n date: tag(entry, \"published\") || tag(entry, \"updated\"),\n }));\n return items.filter((item) => item.title && /^https?:\\/\\//i.test(item.link));\n}\n\nfunction formatDate(date: string, timeZone: string | undefined, language: string | undefined): string {\n const parsed = new Date(date);\n if (Number.isNaN(parsed.getTime())) return \"\";\n return parsed.toLocaleDateString(language || \"en\", { month: \"long\", day: \"numeric\", year: \"numeric\", timeZone });\n}\n\n/**\n * The latest items of another site's news feed (RSS or Atom), such as a podcast's episodes or a\n * news site's headlines: each one's title, linking to it, and its date. The feed is read when the\n * site is built, and the block asks for a rebuild the next day, so overnight builds keep it current.\n * Only titles, links and dates are shown, never the items' text.\n */\nconst FeedList: ComponentConfig<FeedListProps> = withParts(\n {\n label: \"News feed\",\n fields: {\n heading: { type: \"text\", label: \"Heading (leave empty for none)\", contentEditable: true },\n feed: { type: \"text\", label: \"The feed's address (RSS or Atom)\", placeholder: \"https://example.org/feed.xml\" },\n limit: { type: \"number\", label: \"How many\", min: 1, max: 50 },\n showDates: {\n type: \"radio\",\n label: \"Show dates\",\n options: [\n { label: \"Yes\", value: true },\n { label: \"No\", value: false },\n ],\n },\n moreLabel: { type: \"text\", label: 'Link under the list, such as \"View all\" (leave empty for none)' },\n moreHref: { type: \"text\", label: \"Where the link goes\" },\n emptyText: { type: \"text\", label: \"Text when the feed can't be read\" },\n className: classNameField,\n },\n defaultProps: {\n heading: \"\",\n feed: \"\",\n limit: 5,\n showDates: true,\n moreLabel: \"\",\n moreHref: \"\",\n emptyText: \"Nothing to show right now.\",\n className: \"\",\n },\n resolveData: async ({ props }, { metadata }) => {\n if (typeof document !== \"undefined\" || !props.feed?.trim()) return { props: { ...props, items: undefined } };\n const site = metadata.site as { timeZone?: string; language?: string } | undefined;\n try {\n // Feeds change daily, so the overnight build should run again tomorrow.\n changesOn(addDays(todayIn(site?.timeZone), 1));\n const items = readFeedItems(await fetchFeed(props.feed))\n .slice(0, Math.max(1, props.limit || 5))\n .map((item) => ({ ...item, date: formatDate(item.date, site?.timeZone, site?.language) }));\n return { props: { ...props, items } };\n } catch (error) {\n console.warn(\n `The feed at ${props.feed} couldn't be read, so the page is built without it: ${(error as Error).message}`,\n );\n return { props: { ...props, items: [] } };\n }\n },\n render: ({ heading, feed, items, showDates, moreLabel, moreHref, emptyText, className, puck }) => {\n const part = useParts();\n return (\n <div className={part(\"gf-feed-list\", \"text-foreground\", className)}>\n {heading && (\n <h2 className={part(\"gf-feed-list-heading\", \"font-heading text-2xl font-semibold\")}>{heading}</h2>\n )}\n {items && items.length > 0 ? (\n <ul className={part(\"gf-feed-list-items\", \"mt-3 divide-y divide-border\")}>\n {items.map((item, index) => (\n // biome-ignore lint/suspicious/noArrayIndexKey: two items can link to the same address\n <li key={`${index}-${item.link}`} className={part(\"gf-feed-list-item\", \"py-2\")}>\n <SiteLink href={item.link} className={part(\"gf-feed-list-link\", \"font-medium hover:underline\")}>\n {item.title}\n </SiteLink>\n {showDates && item.date && (\n <span className={part(\"gf-feed-list-date\", \"block text-sm text-muted-foreground\")}>\n {item.date}\n </span>\n )}\n </li>\n ))}\n </ul>\n ) : (\n <p className={part(\"gf-feed-list-empty\", \"mt-3 text-muted-foreground\")}>\n {puck?.isEditing && feed ? `The latest items from ${feed} appear here on the site.` : emptyText}\n </p>\n )}\n {moreLabel && moreHref && (\n <SiteLink\n href={moreHref}\n className={part(\n \"gf-feed-list-more\",\n \"mt-3 inline-block text-sm font-semibold text-primary hover:underline\",\n )}\n >\n {moreLabel}\n </SiteLink>\n )}\n </div>\n );\n },\n },\n {\n \"gf-feed-list\": \"News feed\",\n \"gf-feed-list-heading\": \"Heading\",\n \"gf-feed-list-items\": \"List\",\n \"gf-feed-list-item\": \"Item\",\n \"gf-feed-list-link\": \"Item's title\",\n \"gf-feed-list-date\": \"Item's date\",\n \"gf-feed-list-empty\": \"Text when empty\",\n \"gf-feed-list-more\": \"Link under the list\",\n },\n);\n\nexport default withKeywords(FeedList, [\"rss\", \"atom\", \"feed\", \"news\", \"podcast\", \"headlines\"]);\n"
11
+ "content": "import { addDays, changesOn, readRemoteText, todayIn } from \"@goodfellow-cms/core\";\nimport { classNameField, SiteLink, useParts, withKeywords, withParts } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig } from \"@puckeditor/core\";\n\nexport interface FeedItem {\n title: string;\n link: string;\n date: string;\n}\n\nexport interface FeedListProps {\n heading: string;\n feed: string;\n limit: number;\n showDates: boolean;\n moreLabel: string;\n moreHref: string;\n emptyText: string;\n className: string;\n /** Read from the feed when the site is built. */\n items?: FeedItem[];\n}\n\n/** How long a build reuses a feed it has read. */\nconst FEED_CACHE_MS = 600_000;\nconst FEED_LIMIT = 5_242_880;\nconst feedCache = new Map<string, { at: number; text: Promise<string> }>();\n\n/**\n * Fetches an RSS or Atom feed from an `https://` address, once every few\n * minutes, never from this computer or a private network, since builds run\n * on CI's and developers' computers.\n */\nasync function fetchFeed(address: string): Promise<string> {\n const url = address.trim();\n const cached = feedCache.get(url);\n if (cached && Date.now() - cached.at < FEED_CACHE_MS) return cached.text;\n const text = readRemoteText(url, {\n what: \"feed\",\n accept: \"application/rss+xml, application/atom+xml, application/xml, text/xml\",\n limit: FEED_LIMIT,\n });\n feedCache.set(url, { at: Date.now(), text });\n text.catch(() => feedCache.delete(url));\n return text;\n}\n\nconst entities: Record<string, string> = { amp: \"&\", lt: \"<\", gt: \">\", quot: '\"', apos: \"'\", nbsp: \" \" };\n\n/** A tag's text, without CDATA markers, tags or entities. */\nfunction decode(text: string): string {\n return text\n .replace(/<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>/g, \"$1\")\n .replace(/<[^>]*>/g, \"\")\n .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16)))\n .replace(/&#(\\d+);/g, (_, code) => String.fromCodePoint(Number(code)))\n .replace(/&([a-z]+);/gi, (match, name) => entities[name.toLowerCase()] ?? match)\n .trim();\n}\n\nfunction tag(xml: string, name: string): string {\n const match = xml.match(new RegExp(`<${name}(?:\\\\s[^>]*)?>([\\\\s\\\\S]*?)</${name}>`, \"i\"));\n return decode(match?.[1] ?? \"\");\n}\n\n/** An Atom entry's link: its `alternate` one, or the first. */\nfunction atomLink(xml: string): string {\n const links = [...xml.matchAll(/<link\\b([^>]*)\\/?>/gi)].map((match) => match[1] ?? \"\");\n const chosen = links.find((attrs) => !/rel=[\"'](?!alternate)/i.test(attrs)) ?? links[0] ?? \"\";\n return chosen.match(/href=[\"']([^\"']+)[\"']/i)?.[1] ?? \"\";\n}\n\n/** The titles, links and dates of an RSS or Atom feed's items. Their text and descriptions are left out. */\nexport function readFeedItems(xml: string): FeedItem[] {\n const rss = [...xml.matchAll(/<item\\b[^>]*>([\\s\\S]*?)<\\/item>/gi)].map(([, item = \"\"]) => ({\n title: tag(item, \"title\"),\n link: tag(item, \"link\") || tag(item, \"guid\"),\n date: tag(item, \"pubDate\") || tag(item, \"dc:date\"),\n }));\n const items = rss.length\n ? rss\n : [...xml.matchAll(/<entry\\b[^>]*>([\\s\\S]*?)<\\/entry>/gi)].map(([, entry = \"\"]) => ({\n title: tag(entry, \"title\"),\n link: atomLink(entry),\n date: tag(entry, \"published\") || tag(entry, \"updated\"),\n }));\n return items.filter((item) => item.title && /^https?:\\/\\//i.test(item.link));\n}\n\nfunction formatDate(date: string, timeZone: string | undefined, language: string | undefined): string {\n const parsed = new Date(date);\n if (Number.isNaN(parsed.getTime())) return \"\";\n return parsed.toLocaleDateString(language || \"en\", { month: \"long\", day: \"numeric\", year: \"numeric\", timeZone });\n}\n\n/**\n * The latest items of another site's news feed (RSS or Atom), such as a podcast's episodes or a\n * news site's headlines: each one's title, linking to it, and its date. The feed is read when the\n * site is built, and the block asks for a rebuild the next day, so overnight builds keep it current.\n * Only titles, links and dates are shown, never the items' text.\n */\nconst FeedList: ComponentConfig<FeedListProps> = withParts(\n {\n label: \"News feed\",\n fields: {\n heading: { type: \"text\", label: \"Heading (leave empty for none)\", contentEditable: true },\n feed: { type: \"text\", label: \"The feed's address (RSS or Atom)\", placeholder: \"https://example.org/feed.xml\" },\n limit: { type: \"number\", label: \"How many\", min: 1, max: 50 },\n showDates: {\n type: \"radio\",\n label: \"Show dates\",\n options: [\n { label: \"Yes\", value: true },\n { label: \"No\", value: false },\n ],\n },\n moreLabel: { type: \"text\", label: 'Link under the list, such as \"View all\" (leave empty for none)' },\n moreHref: { type: \"text\", label: \"Where the link goes\" },\n emptyText: { type: \"text\", label: \"Text when the feed can't be read\" },\n className: classNameField,\n },\n defaultProps: {\n heading: \"\",\n feed: \"\",\n limit: 5,\n showDates: true,\n moreLabel: \"\",\n moreHref: \"\",\n emptyText: \"Nothing to show right now.\",\n className: \"\",\n },\n resolveData: async ({ props }, { metadata }) => {\n if (typeof document !== \"undefined\" || !props.feed?.trim()) return { props: { ...props, items: undefined } };\n const site = metadata.site as { timeZone?: string; language?: string } | undefined;\n try {\n // Feeds change daily, so the overnight build should run again tomorrow.\n changesOn(addDays(todayIn(site?.timeZone), 1));\n const items = readFeedItems(await fetchFeed(props.feed))\n .slice(0, Math.max(1, props.limit || 5))\n .map((item) => ({ ...item, date: formatDate(item.date, site?.timeZone, site?.language) }));\n return { props: { ...props, items } };\n } catch (error) {\n console.warn(\n `The feed at ${props.feed} couldn't be read, so the page is built without it: ${(error as Error).message}`,\n );\n return { props: { ...props, items: [] } };\n }\n },\n render: ({ heading, feed, items, showDates, moreLabel, moreHref, emptyText, className, puck }) => {\n const part = useParts();\n return (\n <div className={part(\"gf-feed-list\", \"text-foreground\", className)}>\n {heading && (\n <h2 className={part(\"gf-feed-list-heading\", \"font-heading text-2xl font-semibold\")}>{heading}</h2>\n )}\n {items && items.length > 0 ? (\n <ul className={part(\"gf-feed-list-items\", \"mt-3 divide-y divide-border\")}>\n {items.map((item, index) => (\n // biome-ignore lint/suspicious/noArrayIndexKey: two items can link to the same address\n <li key={`${index}-${item.link}`} className={part(\"gf-feed-list-item\", \"py-2\")}>\n <SiteLink href={item.link} className={part(\"gf-feed-list-link\", \"font-medium hover:underline\")}>\n {item.title}\n </SiteLink>\n {showDates && item.date && (\n <span className={part(\"gf-feed-list-date\", \"block text-sm text-muted-foreground\")}>\n {item.date}\n </span>\n )}\n </li>\n ))}\n </ul>\n ) : (\n <p className={part(\"gf-feed-list-empty\", \"mt-3 text-muted-foreground\")}>\n {puck?.isEditing && feed ? `The latest items from ${feed} appear here on the site.` : emptyText}\n </p>\n )}\n {moreLabel && moreHref && (\n <SiteLink\n href={moreHref}\n className={part(\n \"gf-feed-list-more\",\n \"mt-3 inline-block text-sm font-semibold text-primary hover:underline\",\n )}\n >\n {moreLabel}\n </SiteLink>\n )}\n </div>\n );\n },\n },\n {\n \"gf-feed-list\": \"News feed\",\n \"gf-feed-list-heading\": \"Heading\",\n \"gf-feed-list-items\": \"List\",\n \"gf-feed-list-item\": \"Item\",\n \"gf-feed-list-link\": \"Item's title\",\n \"gf-feed-list-date\": \"Item's date\",\n \"gf-feed-list-empty\": \"Text when empty\",\n \"gf-feed-list-more\": \"Link under the list\",\n },\n);\n\nexport default withKeywords(FeedList, [\"rss\", \"atom\", \"feed\", \"news\", \"podcast\", \"headlines\"]);\n"
12
12
  }
13
13
  ],
14
14
  "meta": {
15
15
  "goodfellow": {
16
16
  "category": "Page sections",
17
- "version": "0.8.0"
17
+ "version": "0.10.0"
18
18
  }
19
19
  },
20
20
  "dependencies": []
package/r/logos.json CHANGED
@@ -14,7 +14,7 @@
14
14
  "meta": {
15
15
  "goodfellow": {
16
16
  "category": "Page sections",
17
- "version": "0.8.0"
17
+ "version": "0.10.0"
18
18
  }
19
19
  },
20
20
  "dependencies": []
package/r/map.json CHANGED
@@ -3,18 +3,18 @@
3
3
  "name": "map",
4
4
  "type": "registry:block",
5
5
  "title": "Map",
6
- "description": "A Google map of an address or place, or one from Google Maps' embed code. No API key needed.",
6
+ "description": "A Google map of an address or place, or one from Google Maps' or OpenStreetMap's embed code. No API key needed.",
7
7
  "files": [
8
8
  {
9
9
  "path": "blocks/installed/map/block.tsx",
10
10
  "type": "registry:block",
11
- "content": "import { ConsentFrame, classNameField, useParts, withKeywords, withParts } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig } from \"@puckeditor/core\";\n\nexport interface MapProps {\n query: string;\n title: string;\n zoom: number;\n height: number;\n className: string;\n}\n\n/**\n * The map's address: Google Maps' own embed address, from the embed code its\n * Share button gives (or that address itself), or else a map of the address\n * or place, which needs no key. Nothing but Google Maps' embedded maps is shown.\n */\nexport function mapAddress(input: string, zoom: number): string | undefined {\n const text = input.trim();\n if (!text) return undefined;\n const embedded = /<iframe\\b[^>]*\\bsrc=[\"']([^\"']+)[\"']/i.exec(text)?.[1]?.replaceAll(\"&amp;\", \"&\") ?? text;\n if (/^https:\\/\\//i.test(embedded)) {\n try {\n const url = new URL(embedded);\n return url.hostname === \"www.google.com\" && url.pathname === \"/maps/embed\" ? url.href : undefined;\n } catch {\n return undefined;\n }\n }\n if (text.includes(\"<\")) return undefined;\n const params = new URLSearchParams({ q: text, z: String(Math.min(21, Math.max(1, zoom || 15))), output: \"embed\" });\n return `https://maps.google.com/maps?${params}`;\n}\n\nfunction MapView({ query, title, zoom, height, className, puck }: MapProps & { puck?: { isEditing?: boolean } }) {\n const part = useParts();\n const src = mapAddress(query, zoom);\n if (!src) {\n // Only editors see why there's no map; visitors see nothing.\n if (!puck?.isEditing) return null;\n return (\n <div className={part(\"gf-map\", \"rounded-lg bg-muted p-6 text-muted-foreground\", className)}>\n {query.trim()\n ? \"This isn't an address or Google Maps' embed code, so no map is shown.\"\n : \"Enter an address to show a map.\"}\n </div>\n );\n }\n return (\n // On a site that asks visitors before loading other sites' content, Google's map waits until they agree.\n <ConsentFrame\n box\n src={src}\n title={title || \"Map\"}\n loading=\"lazy\"\n referrerPolicy=\"no-referrer-when-downgrade\"\n // Google's map needs its scripts and its own address; it can open Google Maps, but nothing else.\n sandbox=\"allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox\"\n boxClassName={className}\n className={part(\"gf-map\", \"w-full rounded-lg border border-border\")}\n style={{ height: Math.max(200, height || 400) }}\n />\n );\n}\n\n/** A Google map of an address or place, which visitors can move around and open in Google Maps. No API key needed. */\nconst MapBlock: ComponentConfig<MapProps> = withParts(\n {\n label: \"Map\",\n fields: {\n query: {\n type: \"textarea\",\n label: \"Address or place, or the embed code from Google Maps' Share button\",\n placeholder: \"1600 Pennsylvania Ave NW, Washington, DC\",\n },\n title: { type: \"text\", label: \"What the map shows, for screen readers\" },\n zoom: { type: \"number\", label: \"Zoom, for an address (1 is the world, 21 a building)\", min: 1, max: 21 },\n height: { type: \"number\", label: \"Height in pixels\", min: 200, max: 900 },\n className: classNameField,\n },\n defaultProps: { query: \"\", title: \"Map\", zoom: 15, height: 400, className: \"\" },\n render: (props) => <MapView {...props} />,\n },\n { \"gf-map\": \"Map\" },\n);\n\nexport default withKeywords(MapBlock, [\"map\", \"google maps\", \"address\", \"location\", \"directions\"]);\n"
11
+ "content": "import { ConsentFrame, classNameField, useParts, withKeywords, withParts } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig } from \"@puckeditor/core\";\n\ntype MapService = \"google\" | \"openstreetmap\";\n\nexport interface MapProps {\n /** Whose map it shows: Google Maps where it's missing. */\n service?: MapService;\n query: string;\n title: string;\n zoom: number;\n height: number;\n className: string;\n}\n\nconst OSM = \"https://www.openstreetmap.org\";\n\n/** A latitude and longitude written as `51.5055, -0.0754`, or `undefined`. */\nfunction coordinates(text: string): { lat: number; lon: number } | undefined {\n const match = /^\\s*(-?\\d+(?:\\.\\d+)?)\\s*,\\s*(-?\\d+(?:\\.\\d+)?)\\s*$/.exec(text);\n if (!match) return undefined;\n const lat = Number(match[1]);\n const lon = Number(match[2]);\n return Math.abs(lat) <= 90 && Math.abs(lon) <= 180 ? { lat, lon } : undefined;\n}\n\n/** OpenStreetMap's embedded map around a point, with a marker on it, about as wide as a page's column at `zoom`. */\nfunction osmMapAt(lat: number, lon: number, zoom: number): string {\n const z = Math.min(19, Math.max(1, Math.round(zoom || 15)));\n const width = (360 / 2 ** z) * (700 / 256);\n const height = width * 0.6 * Math.cos((lat * Math.PI) / 180);\n const round = (value: number) => Number(value.toFixed(6));\n const bbox = [lon - width / 2, lat - height / 2, lon + width / 2, lat + height / 2].map(round).join(\",\");\n return `${OSM}/export/embed.html?bbox=${bbox}&layer=mapnik&marker=${round(lat)},${round(lon)}`;\n}\n\n/**\n * OpenStreetMap's embedded map: from the embed code its Share button gives\n * (or that address), a link to a place on it (`#map=17/51.5/-0.07`, with\n * `mlat` and `mlon` for a marker), or a latitude and longitude. Addresses\n * can't be looked up without asking OpenStreetMap's search, which pages\n * and the editor don't, so they're refused.\n */\nexport function osmMapAddress(input: string, zoom: number): string | undefined {\n const text = input.trim();\n if (!text) return undefined;\n const point = coordinates(text);\n if (point) return osmMapAt(point.lat, point.lon, zoom);\n const given = /<iframe\\b[^>]*\\bsrc=[\"']([^\"']+)[\"']/i.exec(text)?.[1]?.replaceAll(\"&amp;\", \"&\") ?? text;\n let url: URL;\n try {\n url = new URL(given);\n } catch {\n return undefined;\n }\n if (url.protocol !== \"https:\" || !/^(www\\.)?openstreetmap\\.org$/.test(url.hostname)) return undefined;\n if (url.pathname === \"/export/embed.html\") {\n const bbox = url.searchParams.get(\"bbox\") ?? \"\";\n if (!/^-?[\\d.]+,-?[\\d.]+,-?[\\d.]+,-?[\\d.]+$/.test(bbox)) return undefined;\n const marker = url.searchParams.get(\"marker\");\n const params = new URLSearchParams({ bbox, layer: \"mapnik\" });\n if (marker && coordinates(marker)) params.set(\"marker\", marker);\n return `${OSM}/export/embed.html?${params.toString().replaceAll(\"%2C\", \",\")}`;\n }\n const view = /map=(\\d+)\\/(-?[\\d.]+)\\/(-?[\\d.]+)/.exec(url.hash);\n const mlat = url.searchParams.get(\"mlat\");\n const mlon = url.searchParams.get(\"mlon\");\n const marked = mlat && mlon ? coordinates(`${mlat},${mlon}`) : undefined;\n const centre = marked ?? (view ? coordinates(`${view[2]},${view[3]}`) : undefined);\n return centre ? osmMapAt(centre.lat, centre.lon, view ? Number(view[1]) : zoom) : undefined;\n}\n\n/**\n * The map's address: Google Maps' own embed address, from the embed code its\n * Share button gives (or that address itself), or else a map of the address\n * or place, which needs no key. Nothing but Google Maps' embedded maps is shown.\n */\nexport function mapAddress(input: string, zoom: number): string | undefined {\n const text = input.trim();\n if (!text) return undefined;\n const embedded = /<iframe\\b[^>]*\\bsrc=[\"']([^\"']+)[\"']/i.exec(text)?.[1]?.replaceAll(\"&amp;\", \"&\") ?? text;\n if (/^https:\\/\\//i.test(embedded)) {\n try {\n const url = new URL(embedded);\n return url.hostname === \"www.google.com\" && url.pathname === \"/maps/embed\" ? url.href : undefined;\n } catch {\n return undefined;\n }\n }\n if (text.includes(\"<\")) return undefined;\n const params = new URLSearchParams({ q: text, z: String(Math.min(21, Math.max(1, zoom || 15))), output: \"embed\" });\n return `https://maps.google.com/maps?${params}`;\n}\n\nfunction MapView({\n service,\n query,\n title,\n zoom,\n height,\n className,\n puck,\n}: MapProps & { puck?: { isEditing?: boolean } }) {\n const part = useParts();\n const osm = service === \"openstreetmap\";\n const src = osm ? osmMapAddress(query, zoom) : mapAddress(query, zoom);\n if (!src) {\n // Only editors see why there's no map; visitors see nothing.\n if (!puck?.isEditing) return null;\n return (\n <div className={part(\"gf-map\", \"rounded-lg bg-muted p-6 text-muted-foreground\", className)}>\n {osm\n ? query.trim()\n ? \"OpenStreetMap needs its embed code, a link to a place on it, or a latitude and longitude, such as 51.5055, -0.0754. It can't look addresses up.\"\n : \"Paste the embed code from OpenStreetMap's Share button, or a latitude and longitude, to show a map.\"\n : query.trim()\n ? \"This isn't an address or Google Maps' embed code, so no map is shown.\"\n : \"Enter an address to show a map.\"}\n </div>\n );\n }\n return (\n // On a site that asks visitors before loading other sites' content, Google's map waits until they agree.\n <ConsentFrame\n box\n src={src}\n title={title || \"Map\"}\n loading=\"lazy\"\n referrerPolicy=\"no-referrer-when-downgrade\"\n // The map needs its scripts and its own address; it can open its site in a new tab, but nothing else.\n sandbox=\"allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox\"\n boxClassName={className}\n className={part(\"gf-map\", \"w-full rounded-lg border border-border\")}\n style={{ height: Math.max(200, height || 400) }}\n />\n );\n}\n\n/**\n * A map of an address or place, which visitors can move around: Google\n * Maps', or OpenStreetMap's from its embed code or a place's position. No API\n * key needed for either.\n */\nconst MapBlock: ComponentConfig<MapProps> = withParts(\n {\n label: \"Map\",\n fields: {\n service: {\n type: \"radio\",\n label: \"Map\",\n options: [\n { label: \"Google Maps\", value: \"google\" },\n { label: \"OpenStreetMap\", value: \"openstreetmap\" },\n ],\n },\n query: {\n type: \"textarea\",\n label: \"Address or place, or the embed code from Google Maps' Share button\",\n placeholder: \"1600 Pennsylvania Ave NW, Washington, DC\",\n },\n title: { type: \"text\", label: \"What the map shows, for screen readers\" },\n zoom: { type: \"number\", label: \"Zoom, for an address (1 is the world, 21 a building)\", min: 1, max: 21 },\n height: { type: \"number\", label: \"Height in pixels\", min: 200, max: 900 },\n className: classNameField,\n },\n defaultProps: { service: \"google\", query: \"\", title: \"Map\", zoom: 15, height: 400, className: \"\" },\n // OpenStreetMap's maps are found by their embed code or a place's position, never an address.\n resolveFields: (data, { fields }) =>\n data.props.service === \"openstreetmap\"\n ? ({\n ...fields,\n query: {\n type: \"textarea\",\n label:\n \"The embed code from OpenStreetMap's Share button, a link to a place on it, or a latitude and longitude\",\n placeholder: \"51.5055, -0.0754\",\n },\n zoom: {\n type: \"number\",\n label: \"Zoom, for a latitude and longitude (1 is the world, 19 a building)\",\n min: 1,\n max: 19,\n },\n } as typeof fields)\n : fields,\n render: (props) => <MapView {...props} />,\n },\n { \"gf-map\": \"Map\" },\n);\n\nexport default withKeywords(MapBlock, [\n \"map\",\n \"google maps\",\n \"openstreetmap\",\n \"osm\",\n \"address\",\n \"location\",\n \"directions\",\n]);\n"
12
12
  }
13
13
  ],
14
14
  "meta": {
15
15
  "goodfellow": {
16
16
  "category": "Page sections",
17
- "version": "0.8.0"
17
+ "version": "0.10.0"
18
18
  }
19
19
  },
20
20
  "dependencies": []
package/r/numbers.json CHANGED
@@ -14,7 +14,7 @@
14
14
  "meta": {
15
15
  "goodfellow": {
16
16
  "category": "Page sections",
17
- "version": "0.8.0"
17
+ "version": "0.10.0"
18
18
  }
19
19
  },
20
20
  "dependencies": []
package/r/panel.json CHANGED
@@ -14,7 +14,7 @@
14
14
  "meta": {
15
15
  "goodfellow": {
16
16
  "category": "Page sections",
17
- "version": "0.8.0"
17
+ "version": "0.10.0"
18
18
  }
19
19
  },
20
20
  "dependencies": []
package/r/people.json CHANGED
@@ -14,7 +14,7 @@
14
14
  "meta": {
15
15
  "goodfellow": {
16
16
  "category": "Page sections",
17
- "version": "0.8.0"
17
+ "version": "0.10.0"
18
18
  }
19
19
  },
20
20
  "dependencies": []
package/r/registry.json CHANGED
@@ -21,7 +21,7 @@
21
21
  "goodfellow": {
22
22
  "category": "Page sections",
23
23
  "recommended": true,
24
- "version": "0.8.0"
24
+ "version": "0.10.0"
25
25
  }
26
26
  },
27
27
  "dependencies": []
@@ -45,7 +45,7 @@
45
45
  "goodfellow": {
46
46
  "category": "Page sections",
47
47
  "recommended": true,
48
- "version": "0.8.0"
48
+ "version": "0.10.0"
49
49
  }
50
50
  },
51
51
  "dependencies": []
@@ -69,7 +69,7 @@
69
69
  "goodfellow": {
70
70
  "category": "Page sections",
71
71
  "recommended": true,
72
- "version": "0.8.0"
72
+ "version": "0.10.0"
73
73
  }
74
74
  },
75
75
  "dependencies": []
@@ -92,7 +92,7 @@
92
92
  "goodfellow": {
93
93
  "category": "Page sections",
94
94
  "recommended": true,
95
- "version": "0.8.0"
95
+ "version": "0.10.0"
96
96
  }
97
97
  },
98
98
  "dependencies": []
@@ -119,7 +119,7 @@
119
119
  "meta": {
120
120
  "goodfellow": {
121
121
  "category": "Page sections",
122
- "version": "0.8.0"
122
+ "version": "0.10.0"
123
123
  }
124
124
  }
125
125
  },
@@ -144,7 +144,7 @@
144
144
  "goodfellow": {
145
145
  "category": "Page sections",
146
146
  "recommended": true,
147
- "version": "0.8.0"
147
+ "version": "0.10.0"
148
148
  }
149
149
  }
150
150
  },
@@ -162,7 +162,7 @@
162
162
  "meta": {
163
163
  "goodfellow": {
164
164
  "category": "Page sections",
165
- "version": "0.8.0"
165
+ "version": "0.10.0"
166
166
  }
167
167
  },
168
168
  "dependencies": []
@@ -171,7 +171,7 @@
171
171
  "name": "map",
172
172
  "type": "registry:block",
173
173
  "title": "Map",
174
- "description": "A Google map of an address or place, or one from Google Maps' embed code. No API key needed.",
174
+ "description": "A Google map of an address or place, or one from Google Maps' or OpenStreetMap's embed code. No API key needed.",
175
175
  "files": [
176
176
  {
177
177
  "path": "blocks/installed/map/block.tsx",
@@ -181,7 +181,7 @@
181
181
  "meta": {
182
182
  "goodfellow": {
183
183
  "category": "Page sections",
184
- "version": "0.8.0"
184
+ "version": "0.10.0"
185
185
  }
186
186
  },
187
187
  "dependencies": []
@@ -200,7 +200,7 @@
200
200
  "meta": {
201
201
  "goodfellow": {
202
202
  "category": "Page sections",
203
- "version": "0.8.0"
203
+ "version": "0.10.0"
204
204
  }
205
205
  },
206
206
  "dependencies": []
@@ -219,7 +219,7 @@
219
219
  "meta": {
220
220
  "goodfellow": {
221
221
  "category": "Page sections",
222
- "version": "0.8.0"
222
+ "version": "0.10.0"
223
223
  }
224
224
  },
225
225
  "dependencies": []
@@ -242,7 +242,7 @@
242
242
  "meta": {
243
243
  "goodfellow": {
244
244
  "category": "Interactive",
245
- "version": "0.8.0"
245
+ "version": "0.10.0"
246
246
  }
247
247
  },
248
248
  "dependencies": []
@@ -261,7 +261,7 @@
261
261
  "meta": {
262
262
  "goodfellow": {
263
263
  "category": "Navigation",
264
- "version": "0.8.0"
264
+ "version": "0.10.0"
265
265
  }
266
266
  },
267
267
  "dependencies": []
@@ -280,7 +280,7 @@
280
280
  "meta": {
281
281
  "goodfellow": {
282
282
  "category": "Page sections",
283
- "version": "0.8.0"
283
+ "version": "0.10.0"
284
284
  }
285
285
  },
286
286
  "dependencies": []
@@ -299,7 +299,7 @@
299
299
  "meta": {
300
300
  "goodfellow": {
301
301
  "category": "Page sections",
302
- "version": "0.8.0"
302
+ "version": "0.10.0"
303
303
  }
304
304
  },
305
305
  "dependencies": []
@@ -318,7 +318,7 @@
318
318
  "meta": {
319
319
  "goodfellow": {
320
320
  "category": "Page sections",
321
- "version": "0.8.0"
321
+ "version": "0.10.0"
322
322
  }
323
323
  },
324
324
  "dependencies": []
@@ -337,7 +337,7 @@
337
337
  "meta": {
338
338
  "goodfellow": {
339
339
  "category": "Page sections",
340
- "version": "0.8.0"
340
+ "version": "0.10.0"
341
341
  }
342
342
  },
343
343
  "dependencies": []
@@ -360,7 +360,7 @@
360
360
  "meta": {
361
361
  "goodfellow": {
362
362
  "category": "Interactive",
363
- "version": "0.8.0"
363
+ "version": "0.10.0"
364
364
  }
365
365
  },
366
366
  "dependencies": []
@@ -369,12 +369,20 @@
369
369
  "name": "comments",
370
370
  "type": "registry:block",
371
371
  "title": "Comments",
372
- "description": "Comments on the page through Giscus, kept as GitHub Discussions; visitors sign in with GitHub to comment.",
372
+ "description": "Comments on the page through Giscus, kept as GitHub Discussions, where visitors sign in with GitHub to comment, through Disqus, or through the site owner's own Garrul.",
373
373
  "files": [
374
374
  {
375
375
  "path": "blocks/installed/comments/block.tsx",
376
376
  "type": "registry:block"
377
377
  },
378
+ {
379
+ "path": "blocks/installed/comments/disqus-frame.tsx",
380
+ "type": "registry:component"
381
+ },
382
+ {
383
+ "path": "blocks/installed/comments/garrul-frame.tsx",
384
+ "type": "registry:component"
385
+ },
378
386
  {
379
387
  "path": "blocks/installed/comments/giscus-frame.tsx",
380
388
  "type": "registry:component"
@@ -383,7 +391,7 @@
383
391
  "meta": {
384
392
  "goodfellow": {
385
393
  "category": "Interactive",
386
- "version": "0.8.0"
394
+ "version": "0.10.0"
387
395
  }
388
396
  },
389
397
  "dependencies": []
@@ -410,7 +418,7 @@
410
418
  "meta": {
411
419
  "goodfellow": {
412
420
  "category": "Advanced",
413
- "version": "0.8.0"
421
+ "version": "0.10.0"
414
422
  }
415
423
  },
416
424
  "dependencies": []
@@ -437,7 +445,7 @@
437
445
  "goodfellow": {
438
446
  "category": "Interactive",
439
447
  "recommended": true,
440
- "version": "0.8.0"
448
+ "version": "0.10.0"
441
449
  }
442
450
  },
443
451
  "dependencies": []
@@ -464,7 +472,7 @@
464
472
  "goodfellow": {
465
473
  "category": "Interactive",
466
474
  "recommended": true,
467
- "version": "0.8.0"
475
+ "version": "0.10.0"
468
476
  }
469
477
  },
470
478
  "dependencies": []
@@ -490,7 +498,7 @@
490
498
  "meta": {
491
499
  "goodfellow": {
492
500
  "category": "Interactive",
493
- "version": "0.8.0"
501
+ "version": "0.10.0"
494
502
  }
495
503
  },
496
504
  "dependencies": []
@@ -14,7 +14,7 @@
14
14
  "meta": {
15
15
  "goodfellow": {
16
16
  "category": "Navigation",
17
- "version": "0.8.0"
17
+ "version": "0.10.0"
18
18
  }
19
19
  },
20
20
  "dependencies": []
@@ -19,7 +19,7 @@
19
19
  "goodfellow": {
20
20
  "category": "Page sections",
21
21
  "recommended": true,
22
- "version": "0.8.0"
22
+ "version": "0.10.0"
23
23
  }
24
24
  },
25
25
  "dependencies": []
@@ -19,7 +19,7 @@
19
19
  "goodfellow": {
20
20
  "category": "Page sections",
21
21
  "recommended": true,
22
- "version": "0.8.0"
22
+ "version": "0.10.0"
23
23
  }
24
24
  },
25
25
  "dependencies": []
@@ -22,7 +22,7 @@
22
22
  "meta": {
23
23
  "goodfellow": {
24
24
  "category": "Interactive",
25
- "version": "0.8.0"
25
+ "version": "0.10.0"
26
26
  }
27
27
  },
28
28
  "dependencies": []
package/r/shadcn-faq.json CHANGED
@@ -23,7 +23,7 @@
23
23
  "goodfellow": {
24
24
  "category": "Interactive",
25
25
  "recommended": true,
26
- "version": "0.8.0"
26
+ "version": "0.10.0"
27
27
  }
28
28
  },
29
29
  "dependencies": []
@@ -18,7 +18,7 @@
18
18
  "goodfellow": {
19
19
  "category": "Page sections",
20
20
  "recommended": true,
21
- "version": "0.8.0"
21
+ "version": "0.10.0"
22
22
  }
23
23
  },
24
24
  "dependencies": []
@@ -21,7 +21,7 @@
21
21
  "goodfellow": {
22
22
  "category": "Page sections",
23
23
  "recommended": true,
24
- "version": "0.8.0"
24
+ "version": "0.10.0"
25
25
  }
26
26
  }
27
27
  }
@@ -22,7 +22,7 @@
22
22
  "meta": {
23
23
  "goodfellow": {
24
24
  "category": "Page sections",
25
- "version": "0.8.0"
25
+ "version": "0.10.0"
26
26
  }
27
27
  }
28
28
  }
@@ -23,7 +23,7 @@
23
23
  "goodfellow": {
24
24
  "category": "Interactive",
25
25
  "recommended": true,
26
- "version": "0.8.0"
26
+ "version": "0.10.0"
27
27
  }
28
28
  },
29
29
  "dependencies": []
@@ -18,7 +18,7 @@
18
18
  "goodfellow": {
19
19
  "category": "Page sections",
20
20
  "recommended": true,
21
- "version": "0.8.0"
21
+ "version": "0.10.0"
22
22
  }
23
23
  },
24
24
  "dependencies": []
@@ -19,7 +19,7 @@
19
19
  "meta": {
20
20
  "goodfellow": {
21
21
  "category": "Interactive",
22
- "version": "0.8.0"
22
+ "version": "0.10.0"
23
23
  }
24
24
  },
25
25
  "dependencies": []
@@ -14,7 +14,7 @@
14
14
  "meta": {
15
15
  "goodfellow": {
16
16
  "category": "Page sections",
17
- "version": "0.8.0"
17
+ "version": "0.10.0"
18
18
  }
19
19
  },
20
20
  "dependencies": []
package/r/timeline.json CHANGED
@@ -14,7 +14,7 @@
14
14
  "meta": {
15
15
  "goodfellow": {
16
16
  "category": "Page sections",
17
- "version": "0.8.0"
17
+ "version": "0.10.0"
18
18
  }
19
19
  },
20
20
  "dependencies": []