-
Notifications
You must be signed in to change notification settings - Fork 0
chore(ci): verify giscus comment threads still map to their articles #168
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
87d2bc1
chore(ci): verify giscus comment threads still map to their articles
Decipher 3d6bf13
fix(ci): attach cause when rethrowing a giscus lookup failure
Decipher 8e72041
fix(ci): register verify-giscus as a knip entry point
Decipher d1da02c
fix(ci): assert giscus threads resolve to their own discussion, not a…
Decipher File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,230 @@ | ||
| #!/usr/bin/env node | ||
| /** | ||
| * Verify that the giscus comment threads still resolve for the articles that | ||
| * have them. | ||
| * | ||
| * Giscus maps a page to a GitHub Discussion by its `pathname`, so the | ||
| * discussion's *title* has to equal `writing/<slug>`. Nothing in the build | ||
| * enforces that: if a slug changes, the URL scheme moves, or the category is | ||
| * edited, the widget silently renders an empty "no comments yet" state instead | ||
| * of erroring. That is how two real discussions sat orphaned under the site's | ||
| * previous `articles/` and `blog/` path schemes, one of them holding an actual | ||
| * reader's comment that nobody could see. | ||
| * | ||
| * This queries the same public resolver the widget calls on every page load, so | ||
| * it needs no credentials and writes nothing. | ||
| * | ||
| * An article with no thread is not a failure. Giscus creates the discussion on | ||
| * the first comment, so most articles legitimately have none. The failure this | ||
| * guards against is a thread that used to resolve and no longer does, which is | ||
| * why known threads are listed explicitly below. | ||
| * | ||
| * Usage: node scripts/verify-giscus.mjs [--json] | ||
| */ | ||
|
|
||
| import { readdir } from 'node:fs/promises' | ||
| import path from 'node:path' | ||
| import { fileURLToPath } from 'node:url' | ||
|
|
||
| // Mirrors the attributes set in app/components/AppGiscusComments.vue. If you | ||
| // change them there, change them here, and vice versa: the component's unit | ||
| // test pins the same values. | ||
| const REPO = 'Decipher/stuar.tc' | ||
| const CATEGORY = 'General' | ||
| const CATEGORY_ID = 'DIC_kwDOGZt9684CAB_7' | ||
|
|
||
| /** | ||
| * Article slugs known to have a discussion thread, mapped to the discussion | ||
| * number that slug must resolve to. | ||
| * | ||
| * The number is not decoration. Giscus matches titles loosely, so a term that | ||
| * is merely a prefix of a real discussion title still resolves: asking for | ||
| * `writing/hello` returns the `writing/hello-world-20211126` thread. Checking | ||
| * only that *something* came back would therefore report a healthy mapping for | ||
| * an article whose own thread does not exist, and would miss two articles | ||
| * colliding onto one thread. Pinning the number makes the assertion an identity | ||
| * check rather than a liveness check. | ||
| * | ||
| * Add a slug here once a thread exists for it; the script prints the exact line | ||
| * to paste. Kept explicit rather than discovered from the GitHub API so the | ||
| * check needs no token, and so removing a thread is a deliberate edit rather | ||
| * than a silent pass. | ||
| */ | ||
| const EXPECTED_THREADS = { | ||
| 'hello-world-20211126': 2, | ||
| 'decoupling-configuration-config-pages-20220412': 82, | ||
| } | ||
|
|
||
| /** Abort a giscus request that has not answered in this long, in ms. */ | ||
| const REQUEST_TIMEOUT_MS = 15000 | ||
|
|
||
| const ARTICLES_DIR = path.join( | ||
| path.dirname(fileURLToPath(import.meta.url)), | ||
| '..', | ||
| 'content', | ||
| 'articles-data', | ||
| ) | ||
|
|
||
| /** | ||
| * Ask giscus to resolve one term, retrying transient failures. | ||
| * | ||
| * A 404 is a definitive "no thread" and is returned as such. Network errors and | ||
| * 5xx are retried, because this check talks to a third-party service and a blip | ||
| * there should not read as a broken site. | ||
| * | ||
| * Mirrors the widget's own non-strict matching rather than forcing | ||
| * `strict=true`, because the point is to observe what a reader's browser | ||
| * actually resolves. The looseness that creates is handled by the caller, which | ||
| * compares the returned discussion number against the expected one. | ||
| * | ||
| * @param {string} term - The pathname-derived discussion title. | ||
| * @param {number} attempts - Remaining tries for transient failures. | ||
| * @returns {Promise<{found: boolean, url?: string, number?: number, comments?: number}>} Result. | ||
| */ | ||
| async function resolveTerm(term, attempts = 3) { | ||
| const qs = new URLSearchParams({ | ||
| repo: REPO, | ||
| term, | ||
| category: CATEGORY, | ||
| categoryId: CATEGORY_ID, | ||
| strict: 'false', | ||
| last: '1', | ||
| }) | ||
| try { | ||
| // Without a signal a hung connection would stall the CI job indefinitely | ||
| // rather than failing into the retry below. | ||
| const res = await fetch(`https://giscus.app/api/discussions?${qs}`, { | ||
| signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), | ||
| }) | ||
| if (res.status === 404) return { found: false } | ||
| if (!res.ok) throw new Error(`HTTP ${res.status}`) | ||
| const body = await res.json() | ||
| const url = body.discussion?.url | ||
| return { | ||
| found: Boolean(body.discussion), | ||
| url, | ||
| number: url ? Number(url.match(/\/discussions\/(\d+)$/)?.[1]) : undefined, | ||
| comments: body.discussion?.totalCommentCount ?? 0, | ||
| } | ||
| } | ||
| catch (err) { | ||
| if (attempts <= 1) { | ||
| throw new Error(`giscus lookup failed for "${term}": ${err.message}`, { cause: err }) | ||
| } | ||
| await new Promise(r => setTimeout(r, 2000)) | ||
| return resolveTerm(term, attempts - 1) | ||
| } | ||
| } | ||
|
|
||
| const files = (await readdir(ARTICLES_DIR)).filter(f => f.endsWith('.json')) | ||
| const articleSlugs = new Set(files.map(f => path.basename(f, '.json'))) | ||
|
|
||
| // Check the union, not just the articles on disk. An expected thread whose | ||
| // article has been renamed away is exactly the orphaning this guards against, | ||
| // and iterating only over the directory would skip it silently. | ||
| const expectedSlugs = Object.keys(EXPECTED_THREADS) | ||
| const slugs = [...new Set([...articleSlugs, ...expectedSlugs])].sort() | ||
|
|
||
| const results = [] | ||
| for (const slug of slugs) { | ||
| const r = await resolveTerm(`writing/${slug}`) | ||
| const expectedNumber = EXPECTED_THREADS[slug] | ||
| results.push({ | ||
| slug, | ||
| ...r, | ||
| expected: expectedNumber !== undefined, | ||
| expectedNumber, | ||
| hasArticle: articleSlugs.has(slug), | ||
| }) | ||
| } | ||
|
|
||
| // A thread with no article is orphaned: readers can never reach it, which is | ||
| // the state this whole check exists because of. | ||
| const orphaned = results.filter(r => r.found && !r.hasArticle) | ||
|
|
||
| const missing = results.filter(r => r.expected && !r.found) | ||
|
|
||
| // Resolved, but to the wrong discussion. Giscus matches titles loosely, so this | ||
| // is how a renamed slug still "resolves" — to a neighbouring article's thread. | ||
| const mismatched = results.filter(r => r.expected && r.found && r.number !== r.expectedNumber) | ||
|
|
||
| // Two articles resolving to one discussion means readers of one see the other's | ||
| // comments. Loose matching makes this reachable whenever one slug is a prefix | ||
| // of another. | ||
| const byNumber = new Map() | ||
| for (const r of results.filter(x => x.found && x.hasArticle)) { | ||
| byNumber.set(r.number, [...(byNumber.get(r.number) ?? []), r.slug]) | ||
| } | ||
| const collisions = [...byNumber.entries()].filter(([, s]) => s.length > 1) | ||
|
|
||
| const undeclared = results.filter(r => !r.expected && r.found) | ||
|
|
||
| if (process.argv.includes('--json')) { | ||
| console.log(JSON.stringify( | ||
| { results, missing, mismatched, orphaned, undeclared, collisions }, | ||
| null, | ||
| 2, | ||
| )) | ||
| } | ||
| else { | ||
| console.log(`giscus mapping: ${REPO} (${CATEGORY}), term = "writing/<slug>"\n`) | ||
| for (const r of results) { | ||
| const mark = r.found ? '✓' : r.expected ? '✗' : '-' | ||
| const detail = r.found | ||
| ? `${r.comments} comment(s) ${r.url}${r.hasArticle ? '' : ' [NO ARTICLE]'}` | ||
| : r.hasArticle ? 'no thread yet' : 'no thread, no article' | ||
| console.log(` ${mark} writing/${r.slug}`.padEnd(66) + detail) | ||
| } | ||
| } | ||
|
|
||
| // Everything below writes to stderr or is suppressed under --json, so that | ||
| // --json emits exactly one JSON document on stdout and stays pipeable to jq. | ||
| const json = process.argv.includes('--json') | ||
|
|
||
| if (undeclared.length && !json) { | ||
| console.log(`\nNote: ${undeclared.length} thread(s) exist that are not in EXPECTED_THREADS.`) | ||
| console.log('Add them to lock in the mapping:') | ||
| for (const r of undeclared) console.log(` '${r.slug}': ${r.number},`) | ||
| } | ||
|
|
||
| if (mismatched.length) { | ||
| console.error(`\nFAIL: ${mismatched.length} thread(s) resolve to the wrong discussion:`) | ||
| for (const r of mismatched) { | ||
| console.error(` writing/${r.slug} expected #${r.expectedNumber}, got #${r.number} ${r.url}`) | ||
| } | ||
| console.error('Giscus matches titles loosely, so a renamed or shortened slug can still') | ||
| console.error('resolve, to a neighbouring article\'s thread. Readers would see the wrong') | ||
| console.error('comments rather than none.') | ||
| } | ||
|
|
||
| if (collisions.length) { | ||
| console.error(`\nFAIL: ${collisions.length} discussion(s) are claimed by more than one article:`) | ||
| for (const [number, slugList] of collisions) { | ||
| console.error(` #${number} <- ${slugList.map(s => `writing/${s}`).join(', ')}`) | ||
| } | ||
| console.error('Readers of one article would see another article\'s comments.') | ||
| } | ||
|
|
||
| if (orphaned.length) { | ||
| console.error(`\nFAIL: ${orphaned.length} thread(s) have no matching article:`) | ||
| for (const r of orphaned) console.error(` writing/${r.slug} ${r.url}`) | ||
| console.error('Readers cannot reach these. Rename the discussion to the current') | ||
| console.error('article path, or drop the slug from EXPECTED_THREADS if it is retired.') | ||
| } | ||
|
|
||
| if (missing.length) { | ||
| console.error(`\nFAIL: ${missing.length} expected thread(s) no longer resolve:`) | ||
| for (const r of missing) { | ||
| console.error(` writing/${r.slug}`) | ||
| } | ||
| console.error('\nThe discussion title must equal the article pathname without a leading slash.') | ||
| console.error('Either the slug changed, the discussion was renamed or deleted, or the') | ||
| console.error('category in AppGiscusComments.vue no longer matches. Readers see an empty') | ||
| console.error('comment box, not an error, so nothing else will report this.') | ||
| } | ||
|
|
||
| if (missing.length || orphaned.length || mismatched.length || collisions.length) process.exit(1) | ||
|
|
||
| if (!json) { | ||
| console.log('\nAll expected threads resolve to their own discussion and map to a published article.') | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
🔎 Supported by static analysis
🏁 Script executed:
Repository: Decipher/stuar.tc
Length of output: 27619
🏁 Script executed:
Repository: Decipher/stuar.tc
Length of output: 11448
🌐 Web query:
Giscus strict matching false API discussions resolver exact semantics term pathname💡 Result:
In Giscus, strict matching is a feature that overrides the default fuzzy search behavior used to resolve GitHub Discussions, providing a deterministic and exact mapping between your web page and the discussion [1][2]. When data-strict="1" is enabled, Giscus changes how it searches for discussions: 1. Mapping Resolution: Giscus first resolves the chosen mapping (e.g., pathname, title, og:title) into a specific value based on your configuration [3][4]. 2. SHA-1 Hashing: Instead of using this raw value as a search term in GitHub's discussion API, Giscus calculates the SHA-1 hash of that value [1][3]. 3. Body Search: Giscus then searches the body of discussions in your configured category for this specific SHA-1 hash [1][3]. Because Giscus searches for this hash within the discussion body rather than matching titles, the discussion title itself becomes irrelevant to the mapping process [3][5]. This allows you to use robust, stable identifiers like the page's pathname while maintaining the ability to change page titles without breaking the comment link [5]. Key Requirements for Strict Mode: - Integration: For existing discussions, you must manually edit the discussion body to include the SHA-1 hash of the mapping value (e.g., the pathname) [1][3]. Giscus automatically includes this hash in the body of any new discussions it creates [1][3]. - Format: The hash does not need to follow a specific format or be an HTML comment; as long as the SHA-1 string is present anywhere in the body, Giscus will successfully resolve the discussion [1][3]. - Reliability: This approach eliminates the risk of "fuzzy" matching errors where Giscus might incorrectly link to a discussion with a similar but incorrect title [1][2].
Citations:
🌐 Web query:
site:github.com/giscus/giscus ADVANCED-USAGE.md strict matching fuzzy search discussion title pathname💡 Result:
In the context of the giscus commenting system, the ADVANCED-USAGE.md documentation outlines the data-strict attribute as a solution to address issues with GitHub's default fuzzy search behavior when locating discussions [1]. By default, giscus uses GitHub's fuzzy search to find a discussion based on its title. This can sometimes lead to the incorrect discussion being selected if multiple discussions have similar titles [1]. To ensure an exact match, users can enable strict title matching by setting data-strict="1" on the giscus script tag [1][2]. When strict matching is enabled, giscus switches from searching by discussion title to searching for a SHA-1 hash of the title within the discussion body [1]. Key requirements for strict matching: 1. Enabling: You must set data-strict="1" in your giscus configuration [1][2]. 2. Migration: For existing discussions, you must manually edit the discussion body to include the SHA-1 hash of the title [1][2]. 3. Automated Handling: New discussions created by giscus after this feature was introduced automatically include the hash as an HTML comment (e.g., ), ensuring they are compatible by default [1]. 4. Flexibility: The hash does not need to be in any specific format; it only needs to be present somewhere in the discussion body [1][2]. While giscus documentation discusses mapping discussions to pages (often using the discussion title as the identifier), it does not natively provide a "pathname" mode as a primary configuration for discussion lookup in the same way it addresses strict title matching. The primary mechanism for ensuring a precise, non-fuzzy link between a page and a discussion is this SHA-1 based strict matching [1][2].
Citations:
Do not accept a non-strict resolver hit as an exact mapping.
resolveTerm()sendsstrict=falseto Giscus, whose non-strict resolver uses fuzzy title matching. Sincefoundonly checksBoolean(body.discussion), a similarly titled discussion may make the expected thread pass while the intended discussion is missing. Use strict matching in this script andAppGiscusComments.vue, or validate the returned discussion identity againstwriting/${slug}before settingfound.🤖 Prompt for AI Agents