Skip to content

Commit 62e85c2

Browse files
authored
Merge pull request ZecHub#734 from dismad/main
Markdown parser fix
2 parents 1d9e931 + 20ae6a6 commit 62e85c2

4 files changed

Lines changed: 151 additions & 9 deletions

File tree

src/app/[locale]/globals.css

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
@import "tailwindcss";
22
@import "nprogress/nprogress.css";
3-
@plugin "flowbite/plugin";
3+
@plugin "flowbite/plugin" {
4+
charts: false;
5+
}
46
@plugin "tailwindcss-animate";
57
@import "../../components/Charts/index.css";
68
@import "../../components/Sitemap/sitemap.css";
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* Regression tests for content-path resolution via transformUri.
3+
*
4+
* The bug these guard against: uppercaseWords used a plain `.includes()` +
5+
* `.replace()`, so the token "Zec" matched as a *prefix* of "Zecmap" /
6+
* "ZecHub" and rewrote the content path to a non-existent file
7+
* (`ZECmap.md` instead of `Zecmap.md`). The page then rendered the empty
8+
* "Browse the articles…" placeholder at HTTP 200.
9+
*
10+
* Fix: only rewrite whole path segments (bounded by start/end, `/`, or `_`).
11+
*
12+
* helpers.ts imports getRootCached from authAndFetch (for firstFileForFolders),
13+
* which pulls in next/cache. Mock that module so this pure unit test never
14+
* loads the Next server runtime (TextEncoder / unstable_cache).
15+
*/
16+
17+
jest.mock("../authAndFetch", () => ({
18+
getRootCached: jest.fn(async () => []),
19+
}));
20+
21+
import { transformUri, getDynamicRoute, resolveContentPath } from "../helpers";
22+
23+
describe("transformUri — whole-segment uppercase/lowercase words", () => {
24+
it("does not rewrite Zec inside Zecmap", () => {
25+
expect(transformUri("/using-zcash/zecmap")).toBe("/Using_Zcash/Zecmap");
26+
});
27+
28+
it("still uppercases a standalone Zec segment to ZEC", () => {
29+
expect(transformUri("/using-zcash/buying-zec")).toBe(
30+
"/Using_Zcash/Buying_ZEC",
31+
);
32+
});
33+
34+
it("uppercases Frost → FROST as a whole segment", () => {
35+
expect(transformUri("/zcash-tech/frost")).toBe("/Zcash_Tech/FROST");
36+
});
37+
38+
it("uppercases Nft → NFT as a whole segment", () => {
39+
expect(transformUri("/zcash-community/cypherpunk-zero-nft")).toBe(
40+
"/Zcash_Community/Cypherpunk_Zero_NFT",
41+
);
42+
});
43+
44+
it("maps Zechub → ZecHub via specialWordsMap (no greedy Zec prefix rewrite)", () => {
45+
// Capitalize → What_Is_Zechub; lowercase Is → What_is_Zechub;
46+
// whole-segment rules leave Zechub intact; specialWordsMap → ZecHub.
47+
expect(transformUri("/start-here/what-is-zechub")).toBe(
48+
"/Start_Here/What_is_ZecHub",
49+
);
50+
});
51+
52+
it("handles ZEC_Use_Cases (standalone Zec segment)", () => {
53+
expect(transformUri("/start-here/zec-use-cases")).toBe(
54+
"/Start_Here/ZEC_Use_Cases",
55+
);
56+
});
57+
58+
it("handles zk_SNARKS (Zk lowered + Snarks uppercased)", () => {
59+
expect(transformUri("/zcash-tech/zk-snarks")).toBe(
60+
"/Zcash_Tech/zk_SNARKS",
61+
);
62+
});
63+
64+
it("maps contribute/zechub-dao to ZecHub_DAO", () => {
65+
expect(transformUri("/contribute/zechub-dao")).toBe(
66+
"/contribute/ZecHub_DAO",
67+
);
68+
});
69+
70+
it("maps contribute/zecweekly-newsletter to ZecWeekly_Newsletter", () => {
71+
expect(transformUri("/contribute/zecweekly-newsletter")).toBe(
72+
"/contribute/ZecWeekly_Newsletter",
73+
);
74+
});
75+
});
76+
77+
describe("getDynamicRoute / resolveContentPath", () => {
78+
it("resolves zecmap to the real content filename", () => {
79+
expect(getDynamicRoute(["using-zcash", "zecmap"])).toBe(
80+
"/site/Using_Zcash/Zecmap.md",
81+
);
82+
expect(resolveContentPath(["using-zcash", "zecmap"])).toBe(
83+
"/site/Using_Zcash/Zecmap.md",
84+
);
85+
});
86+
87+
it("resolves buying-zec to Buying_ZEC.md", () => {
88+
expect(getDynamicRoute(["using-zcash", "buying-zec"])).toBe(
89+
"/site/Using_Zcash/Buying_ZEC.md",
90+
);
91+
});
92+
});

src/lib/authAndFetch.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,35 @@ function cleanPath(path: string): string {
1616
return path.replace(/^\/+/, "");
1717
}
1818

19+
/**
20+
* Normalize a path for the GitHub contents API under the content repo.
21+
*
22+
* Callers pass mixed forms:
23+
* - wiki slug roots like `/site/using-zcash` (need transformUri)
24+
* - already-transformed content paths like `site/Using_Zcash` (must NOT
25+
* re-run transformUri — that would turn `site/` into `Site/` and break
26+
* the case-insensitive file fallback folder listing)
27+
*
28+
* Detection: content-repo paths contain uppercase letters (e.g. Using_Zcash).
29+
* Wiki slug paths are lowercase with hyphens (e.g. using-zcash). Only the
30+
* latter go through transformUri.
31+
*/
32+
function toGithubPath(path: string): string {
33+
const p = cleanPath(path);
34+
const hasSitePrefix = /^site\//i.test(p);
35+
const rest = hasSitePrefix ? p.slice(p.indexOf("/") + 1) : p;
36+
37+
// Already a content-repo path (Capitalized_Underscore segments).
38+
if (hasSitePrefix && /[A-Z]/.test(rest)) {
39+
return "site/" + rest;
40+
}
41+
42+
// Wiki slug path — run transformUri on the slug portion.
43+
const withSlash = rest.startsWith("/") ? rest : `/${rest}`;
44+
const transformed = transformUri(withSlash); // e.g. "/Using_Zcash"
45+
return cleanPath("site" + transformed);
46+
}
47+
1948
function assertRepoConfig(): boolean {
2049
if (!owner || !repo) {
2150
console.error(
@@ -306,7 +335,7 @@ export const getRootCached = unstable_cache(
306335
const res = await octokit.rest.repos.getContent({
307336
owner,
308337
repo,
309-
path: cleanPath(transformUri(path).replace("/Site", "/site")),
338+
path: toGithubPath(path),
310339
ref: branch,
311340
});
312341
const data = res.data;
@@ -338,7 +367,7 @@ export async function getRootFileName(path: string) {
338367
const res = await octokit.rest.repos.getContent({
339368
owner,
340369
repo,
341-
path: cleanPath(transformUri(path).replace("/Site", "/site")),
370+
path: toGithubPath(path),
342371
ref: branch,
343372
});
344373
const data = res.data;
@@ -358,7 +387,7 @@ export const getAllMarkdownRecursively = unstable_cache(
358387
const walk = async (currentPath: string, isInitial: boolean) => {
359388
try {
360389
const apiPath = isInitial
361-
? cleanPath(transformUri(currentPath).replace("/Site", "/site"))
390+
? toGithubPath(currentPath)
362391
: cleanPath(currentPath);
363392
const res = await octokit.rest.repos.getContent({
364393
owner,

src/lib/helpers.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,10 @@ const specialWordsMap = {
357357
Faq: "FAQ",
358358
ZECHub: "ZecHub",
359359
ZEChub: "ZecHub",
360+
// After whole-segment rules, "Zec" no longer rewrites inside ZecHub /
361+
// ZecWeekly. Map the remaining mixed-case forms to the on-disk names.
362+
Zechub: "ZecHub",
363+
Zecweekly: "ZecWeekly",
360364
Av_Club: "AV_Club",
361365
guides_For_Creators: "Guides_for_Creators",
362366
Grapheneos: "GrapheneOS",
@@ -381,19 +385,34 @@ const specialWordsMap = {
381385
zkav: "ZKAV",
382386
};
383387

388+
/**
389+
* Apply a word list only to whole path segments.
390+
*
391+
* Segments are bounded by start/end of string, `/`, or `_`. This prevents
392+
* prefix collisions such as "Zec" matching inside "Zecmap" / "ZecHub" (which
393+
* previously rewrote Zecmap → ZECmap and 404'd the content lookup).
394+
*/
395+
function replaceWholeSegments(
396+
input: string,
397+
word: string,
398+
replacement: string,
399+
): string {
400+
const re = new RegExp(`(^|[_/])${word}(?=[_/]|$)`, "g");
401+
return input.replace(re, (_m, boundary: string) => boundary + replacement);
402+
}
403+
384404
export const transformUri = (uri: string, ignoreLowerCase = false) => {
385405
let transformed = uri
386406
.replace(/\b\w/g, (l) => l.toUpperCase())
387407
.replace(/-/g, "_");
388408

389-
if (!ignoreLowerCase)
409+
if (!ignoreLowerCase) {
390410
lowercaseWords.forEach((word) => {
391-
if (transformed.includes(word))
392-
transformed = transformed.replace(word, word.toLowerCase());
411+
transformed = replaceWholeSegments(transformed, word, word.toLowerCase());
393412
});
413+
}
394414
uppercaseWords.forEach((word) => {
395-
if (transformed.includes(word))
396-
transformed = transformed.replace(word, word.toUpperCase());
415+
transformed = replaceWholeSegments(transformed, word, word.toUpperCase());
397416
});
398417
Object.entries(specialWordsMap).forEach(([word, targetWord]) => {
399418
if (transformed.includes(word))

0 commit comments

Comments
 (0)