diff --git a/src/command/render/pandoc.ts b/src/command/render/pandoc.ts
index d4f03d4f19..40d3651cb7 100644
--- a/src/command/render/pandoc.ts
+++ b/src/command/render/pandoc.ts
@@ -207,6 +207,7 @@ import {
BrandFontFile,
BrandFontGoogle,
} from "../../resources/types/schema-types.ts";
+import { kFieldCategories } from "../../project/types/website/listing/website-listing-shared.ts";
// in case we are running multiple pandoc processes
// we need to make sure we capture all of the trace files
@@ -1020,6 +1021,10 @@ export async function runPandoc(
if (key === kTheme && isRevealjsOutput(options.format.pandoc)) {
continue;
}
+ // - categories are handled specifically already for website projects with a metadata override and should not be overridden by user input
+ if (key === kFieldCategories && projectIsWebsite(options.project)) {
+ continue;
+ }
// perform the override
pandocMetadata[key] = engineMetadata[key];
}
diff --git a/src/core/base64.ts b/src/core/base64.ts
new file mode 100644
index 0000000000..8eed9dd778
--- /dev/null
+++ b/src/core/base64.ts
@@ -0,0 +1,7 @@
+export function b64EncodeUnicode(str: string) {
+ return btoa(encodeURIComponent(str));
+}
+
+export function unicodeDecodeB64(str: string) {
+ return decodeURIComponent(atob(str));
+}
diff --git a/src/project/types/website/listing/website-listing-categories.ts b/src/project/types/website/listing/website-listing-categories.ts
index 911aeeaca0..e86d6bfa11 100644
--- a/src/project/types/website/listing/website-listing-categories.ts
+++ b/src/project/types/website/listing/website-listing-categories.ts
@@ -17,6 +17,7 @@ import {
ListingDescriptor,
ListingSharedOptions,
} from "./website-listing-shared.ts";
+import { b64EncodeUnicode } from "../../../../core/base64.ts";
export function categorySidebar(
doc: Document,
@@ -117,7 +118,7 @@ function categoryElement(
categoryEl.classList.add("category");
categoryEl.setAttribute(
"data-category",
- value !== undefined ? btoa(value) : btoa(category),
+ value !== undefined ? b64EncodeUnicode(value) : b64EncodeUnicode(category),
);
categoryEl.innerHTML = contents;
return categoryEl;
diff --git a/src/project/types/website/listing/website-listing-template.ts b/src/project/types/website/listing/website-listing-template.ts
index c3e270db03..335deeaaa0 100644
--- a/src/project/types/website/listing/website-listing-template.ts
+++ b/src/project/types/website/listing/website-listing-template.ts
@@ -44,6 +44,7 @@ import { formatDate, parsePandocDate } from "../../../../core/date.ts";
import { truncateText } from "../../../../core/text.ts";
import { encodeAttributeValue } from "../../../../core/html.ts";
import { imagePlaceholder, isPlaceHolder } from "./website-listing-read.ts";
+import { b64EncodeUnicode, unicodeDecodeB64 } from "../../../../core/base64.ts";
export const kDateFormat = "date-format";
@@ -160,6 +161,12 @@ export function templateMarkdownHandler(
ejsParams["metadataAttrs"] = reshapedListing.utilities.metadataAttrs;
ejsParams["templateParams"] = reshapedListing["template-params"];
}
+ // some custom utils function
+ ejsParams["utils"] = {
+ b64encode: b64EncodeUnicode,
+ b64decode: unicodeDecodeB64,
+ };
+
return ejsParams;
};
@@ -455,7 +462,7 @@ export function reshapeListing(
attr["index"] = (index++).toString();
if (item.categories) {
const str = (item.categories as string[]).join(",");
- attr["categories"] = btoa(str);
+ attr["categories"] = b64EncodeUnicode(str);
}
// Add magic attributes for the sortable values
diff --git a/src/project/types/website/website.ts b/src/project/types/website/website.ts
index 70805522f7..2f8bc5063e 100644
--- a/src/project/types/website/website.ts
+++ b/src/project/types/website/website.ts
@@ -86,6 +86,9 @@ import { formatDate } from "../../../core/date.ts";
import { projectExtensionPathResolver } from "../../../extension/extension.ts";
import { websiteDraftPostProcessor } from "./website-draft.ts";
import { projectDraftMode } from "./website-utils.ts";
+import { kFieldCategories } from "./listing/website-listing-shared.ts";
+import { pandocNativeStr } from "../../../core/pandoc/codegen.ts";
+import { asArray } from "../../../core/array.ts";
export const kSiteTemplateDefault = "default";
export const kSiteTemplateBlog = "blog";
@@ -157,6 +160,7 @@ export const websiteProjectType: ProjectType = {
// add some title related variables
extras.pandoc = extras.pandoc || {};
extras.metadata = extras.metadata || {};
+ extras.metadataOverride = extras.metadataOverride || {};
// Resolve any giscus information
resolveFormatForGiscus(project, format);
@@ -196,6 +200,18 @@ export const websiteProjectType: ProjectType = {
extras.metadata[kPageTitle] = title;
}
+ // categories metadata needs to be escaped from Markdown processing to
+ // avoid +smart applying to it. Categories are expected to be non markdown.
+ // So we provide an override to ensure they are not processed.
+ if (format.metadata[kFieldCategories]) {
+ extras.metadataOverride[kFieldCategories] = asArray(
+ format.metadata[kFieldCategories],
+ ).map(
+ (category) =>
+ pandocNativeStr(category as string).mappedString().value,
+ );
+ }
+
// html metadata
extras.html = extras.html || {};
extras.html[kHtmlPostprocessors] = extras.html[kHtmlPostprocessors] || [];
diff --git a/src/resources/projects/website/listing/item-default.ejs.md b/src/resources/projects/website/listing/item-default.ejs.md
index d39324c671..278b153b32 100644
--- a/src/resources/projects/website/listing/item-default.ejs.md
+++ b/src/resources/projects/website/listing/item-default.ejs.md
@@ -56,7 +56,7 @@ print(`
${listing.utilities.outputLi
<% if (fields.includes('categories') && item.categories) { %>
<% for (const category of item.categories) { %>
-
<%= category %>
+
<%= category %>
<% } %>
<% } %>
diff --git a/src/resources/projects/website/listing/item-grid.ejs.md b/src/resources/projects/website/listing/item-grid.ejs.md
index 8c2423bcd0..860e77ad6a 100644
--- a/src/resources/projects/website/listing/item-grid.ejs.md
+++ b/src/resources/projects/website/listing/item-grid.ejs.md
@@ -64,7 +64,7 @@ return !["title", "image", "image-alt", "date", "author", "subtitle", "descripti
<% for (const category of item.categories) { %>
-
<%= category %>
+
<%= category %>
<% } %>
diff --git a/src/resources/projects/website/listing/listing-default.ejs.md b/src/resources/projects/website/listing/listing-default.ejs.md
index 6146d61917..a191a600f2 100644
--- a/src/resources/projects/website/listing/listing-default.ejs.md
+++ b/src/resources/projects/website/listing/listing-default.ejs.md
@@ -1,5 +1,5 @@
:::{.list .quarto-listing-default}
<% for (const item of items) { %>
-<% partial('item-default.ejs.md', {listing, item }) %>
+<% partial('item-default.ejs.md', {listing, item, utils }) %>
<% } %>
:::
diff --git a/src/resources/projects/website/listing/listing-grid.ejs.md b/src/resources/projects/website/listing/listing-grid.ejs.md
index 441eb8e36e..855865c50c 100644
--- a/src/resources/projects/website/listing/listing-grid.ejs.md
+++ b/src/resources/projects/website/listing/listing-grid.ejs.md
@@ -4,6 +4,6 @@ const cols = listing['grid-columns'];
:::{.list .grid .quarto-listing-cols-<%=cols%>}
<% for (const item of items) { %>
-<% partial('item-grid.ejs.md', {listing, item }) %>
+<% partial('item-grid.ejs.md', {listing, item, utils }) %>
<% } %>
:::
diff --git a/src/resources/projects/website/listing/quarto-listing.js b/src/resources/projects/website/listing/quarto-listing.js
index ac3817ac0b..54d0e1e7f2 100644
--- a/src/resources/projects/website/listing/quarto-listing.js
+++ b/src/resources/projects/website/listing/quarto-listing.js
@@ -16,7 +16,9 @@ window["quarto-listing-loaded"] = () => {
if (hash) {
// If there is a category, switch to that
if (hash.category) {
- activateCategory(hash.category);
+ // category hash are URI encoded so we need to decode it before processing
+ // so that we can match it with the category element processed in JS
+ activateCategory(decodeURIComponent(hash.category));
}
// Paginate a specific listing
const listingIds = Object.keys(window["quarto-listings"]);
@@ -59,7 +61,10 @@ window.document.addEventListener("DOMContentLoaded", function (_event) {
);
for (const categoryEl of categoryEls) {
- const category = atob(categoryEl.getAttribute("data-category"));
+ // category needs to support non ASCII characters
+ const category = decodeURIComponent(
+ atob(categoryEl.getAttribute("data-category"))
+ );
categoryEl.onclick = () => {
activateCategory(category);
setCategoryHash(category);
@@ -209,7 +214,9 @@ function activateCategory(category) {
// Activate this category
const categoryEl = window.document.querySelector(
- `.quarto-listing-category .category[data-category='${btoa(category)}']`
+ `.quarto-listing-category .category[data-category='${btoa(
+ encodeURIComponent(category)
+ )}']`
);
if (categoryEl) {
categoryEl.classList.add("active");
@@ -232,7 +239,9 @@ function filterListingCategory(category) {
list.filter(function (item) {
const itemValues = item.values();
if (itemValues.categories !== null) {
- const categories = atob(itemValues.categories).split(",");
+ const categories = decodeURIComponent(
+ atob(itemValues.categories)
+ ).split(",");
return categories.includes(category);
} else {
return false;
diff --git a/tests/docs/playwright/blog/simple-blog/posts/post-with-code/index.qmd b/tests/docs/playwright/blog/simple-blog/posts/post-with-code/index.qmd
index bb7ef9d8f8..5a9b290c4c 100644
--- a/tests/docs/playwright/blog/simple-blog/posts/post-with-code/index.qmd
+++ b/tests/docs/playwright/blog/simple-blog/posts/post-with-code/index.qmd
@@ -2,7 +2,7 @@
title: "Post With Code"
author: "Harlow Malloc"
date: "2024-09-06"
-categories: [news, code, analysis]
+categories: [news, code, analysis, apos'trophe]
image: "image.jpg"
---
diff --git a/tests/docs/playwright/blog/simple-blog/posts/welcome/index.qmd b/tests/docs/playwright/blog/simple-blog/posts/welcome/index.qmd
index b8cb583f96..d39a92a026 100644
--- a/tests/docs/playwright/blog/simple-blog/posts/welcome/index.qmd
+++ b/tests/docs/playwright/blog/simple-blog/posts/welcome/index.qmd
@@ -2,7 +2,7 @@
title: "Welcome To My Blog"
author: "Tristan O'Malley"
date: "2024-09-03"
-categories: [news]
+categories: [news, 'euros (€)', 免疫]
---
This is the first post in a Quarto blog. Welcome!
diff --git a/tests/docs/smoke-all/2024/10/23/issue-10829/.gitignore b/tests/docs/smoke-all/2024/10/23/issue-10829/.gitignore
new file mode 100644
index 0000000000..92d902f273
--- /dev/null
+++ b/tests/docs/smoke-all/2024/10/23/issue-10829/.gitignore
@@ -0,0 +1,2 @@
+/.quarto/
+_site/
\ No newline at end of file
diff --git a/tests/integration/playwright/tests/blog-simple-blog.spec.ts b/tests/integration/playwright/tests/blog-simple-blog.spec.ts
index b247fe71fc..3494ecd44c 100644
--- a/tests/integration/playwright/tests/blog-simple-blog.spec.ts
+++ b/tests/integration/playwright/tests/blog-simple-blog.spec.ts
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test";
+import { getUrl } from "../src/utils";
test('List.js is correctly patch to allow searching with lowercase and uppercase',
async ({ page }) => {
@@ -29,4 +30,25 @@ test('Categories link are clickable', async ({ page }) => {
await page.locator('div').filter({ hasText: /^news$/ }).click();
await expect(page).toHaveURL(/_site\/index\.html#category=news$/);
await expect(page.locator(`div.category[data-category="${btoa('news')}"]`)).toHaveClass(/active/);
-});
\ No newline at end of file
+});
+
+test('Categories links are clickable', async ({ page }) => {
+ const checkCategoryLink = async (category: string) => {
+ await page.getByRole('link', { name: category }).click();
+ await expect(page).toHaveURL(getUrl(`blog/simple-blog/_site/index.html#category=${encodeURIComponent(category)}`));
+ await expect(page.locator(`div.category[data-category="${btoa(encodeURIComponent(category))}"]`)).toHaveClass(/active/);
+ };
+ // Checking link is working
+ await page.goto('./blog/simple-blog/_site/posts/welcome/');
+ await checkCategoryLink('news');
+ // Including for special characters
+ await page.getByRole('link', { name: 'Welcome To My Blog' }).click();
+ await checkCategoryLink('euros (€)');
+ await page.getByRole('link', { name: 'Welcome To My Blog' }).click();
+ await checkCategoryLink('免疫');
+ await page.goto('./blog/simple-blog/_site/posts/post-with-code/');
+ await checkCategoryLink("apos'trophe");
+ // special check for when a page is not loaded from non root path
+ await page.goto('./blog/simple-blog/_site/posts/welcome/#img-lst');
+ await checkCategoryLink('news');
+});