feat(blog): enable live likes and first-party views
This commit is contained in:
1 parent
89cdb00765
commit
8c19266760
13 files changed
+631
-30
No files matched your search
+17
-3
@@ -83,6 +83,8 @@ The default values are:
|
|||||||
|
|
||||||
```txt
|
```txt
|
||||||
PUBLIC_AIA_API_BASE=https://api.azinstitute4autism.com
|
PUBLIC_AIA_API_BASE=https://api.azinstitute4autism.com
|
||||||
|
PUBLIC_COUNTERS_MODE=off
|
||||||
|
PUBLIC_COUNTERS_LIVE_APPROVED=false
|
||||||
PUBLIC_ALLOW_INDEXING=false
|
PUBLIC_ALLOW_INDEXING=false
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -94,6 +96,16 @@ not deploy anything or change environment files. Run `npm run build` afterward
|
|||||||
to restore staging output. Only explicitly authorized production deployments
|
to restore staging output. Only explicitly authorized production deployments
|
||||||
should publish indexing-enabled artifacts.
|
should publish indexing-enabled artifacts.
|
||||||
|
|
||||||
|
Ordinary local builds keep counters off. `npm run deploy` deliberately and
|
||||||
|
non-overridably builds the production artifact with live counters, the approved
|
||||||
|
API origin and `PUBLIC_COUNTERS_LIVE_APPROVED=true`. Article pages post one
|
||||||
|
first-party view per real navigation; Library cards only batch-read statistics.
|
||||||
|
Likes use the existing POST/DELETE API and store only the visitor's chosen slug
|
||||||
|
set in localStorage. No view cookies or visitor identifiers are created. Run
|
||||||
|
`npm run test:counters`, then build in live mode and run `npm run
|
||||||
|
verify:counters -- --browser=chromium` (or `firefox`) for a fully intercepted
|
||||||
|
browser regression that cannot write production counters.
|
||||||
|
|
||||||
## View And Build
|
## View And Build
|
||||||
|
|
||||||
### Development Server
|
### Development Server
|
||||||
@@ -600,16 +612,18 @@ Use `PUBLIC_AIA_FORMS_MODE=off npm run build` for an offline artifact, or
|
|||||||
`PUBLIC_AIA_FORMS_MODE=mock npm run dev` for synthetic fixtures. Missing/empty
|
`PUBLIC_AIA_FORMS_MODE=mock npm run dev` for synthetic fixtures. Missing/empty
|
||||||
mode means live; obsolete `isolated-test` and other invalid values produce an
|
mode means live; obsolete `isolated-test` and other invalid values produce an
|
||||||
explicit configuration error. Remove old approval/environment variables from
|
explicit configuration error. Remove old approval/environment variables from
|
||||||
local settings. Indexing is independent of forms; counters remain off by default.
|
local settings. Indexing and form mode remain independent of counter mode.
|
||||||
|
|
||||||
See the [current Form A guide](integrations/jotform/README.md) for the exact
|
See the [current Form A guide](integrations/jotform/README.md) for the exact
|
||||||
registry, safeguards, rollback and owner acceptance requirements. Form C remains
|
registry, safeguards, rollback and owner acceptance requirements. Form C remains
|
||||||
inactive. Employee-portal private input retains its own separate gates.
|
inactive. Employee-portal private input retains its own separate gates.
|
||||||
|
|
||||||
Verify matching output with
|
Verify matching output with
|
||||||
`node tools/phase10-artifact-verify.mjs --mode=staging --forms=live`;
|
`node tools/phase10-artifact-verify.mjs --mode=staging --forms=live --counters=off`;
|
||||||
use `--mode=indexing` for indexing output and `--forms=off` or `--forms=mock`
|
use `--mode=indexing` for indexing output and `--forms=off` or `--forms=mock`
|
||||||
for those explicit builds. No actual submissions run in automated tests.
|
for those explicit builds. Production counter artifacts require
|
||||||
|
`--counters=live`. No actual submissions or production counter writes run in
|
||||||
|
automated tests.
|
||||||
|
|
||||||
## Static releases and employee portal
|
## Static releases and employee portal
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,8 @@
|
|||||||
"test:seo": "node --experimental-strip-types --test tools/seo.test.mjs",
|
"test:seo": "node --experimental-strip-types --test tools/seo.test.mjs",
|
||||||
"test:library": "node --experimental-strip-types --test tools/library.test.mjs",
|
"test:library": "node --experimental-strip-types --test tools/library.test.mjs",
|
||||||
"test:campaign": "node --test tools/campaign.test.mjs",
|
"test:campaign": "node --test tools/campaign.test.mjs",
|
||||||
|
"test:counters": "node --experimental-strip-types --test tools/counters.test.mjs",
|
||||||
|
"verify:counters": "node tools/counters-browser.mjs",
|
||||||
"verify:campaign": "node tools/campaign-browser.mjs",
|
"verify:campaign": "node tools/campaign-browser.mjs",
|
||||||
"generate:sitemap": "node tools/generate-sitemap.mjs",
|
"generate:sitemap": "node tools/generate-sitemap.mjs",
|
||||||
"generate:redirects": "node tools/generate-redirects.mjs"
|
"generate:redirects": "node tools/generate-redirects.mjs"
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
---
|
---
|
||||||
import { articleMessages } from '../utils/article-messages';
|
import { articleMessages } from '../utils/article-messages';
|
||||||
import { counterModeForRuntime, resolveCounterConfig } from '../utils/counter-mode';
|
import { counterModeForRuntime, resolveCounterConfig } from '../utils/counter-mode';
|
||||||
interface Props { slug: string; lang: string }
|
interface Props { slug: string; lang: string; trackView?: boolean }
|
||||||
const { slug, lang } = Astro.props;
|
const { slug, lang, trackView = false } = Astro.props;
|
||||||
const text = articleMessages(lang);
|
const text = articleMessages(lang);
|
||||||
const counterConfig = resolveCounterConfig(
|
const counterConfig = resolveCounterConfig(
|
||||||
import.meta.env.PUBLIC_COUNTERS_MODE,
|
import.meta.env.PUBLIC_COUNTERS_MODE,
|
||||||
@@ -15,21 +15,23 @@ const mockResult = import.meta.env.PUBLIC_COUNTERS_MOCK_RESULT === 'failed' || i
|
|||||||
: 'success';
|
: 'success';
|
||||||
const statusId = `counter-status-${slug.replace(/[^a-zA-Z0-9_-]/g, '-')}`;
|
const statusId = `counter-status-${slug.replace(/[^a-zA-Z0-9_-]/g, '-')}`;
|
||||||
---
|
---
|
||||||
<div class="like-view-counter" data-counter data-counter-mode={runtimeMode} data-counter-reason={counterConfig.reason} data-mock-result={mockResult}>
|
<div class="like-view-counter" data-counter data-counter-mode={runtimeMode} data-counter-reason={counterConfig.reason} data-api-base={counterConfig.apiBase} data-slug={slug} data-track-view={trackView ? 'true' : 'false'} data-like-label={text.like} data-unlike-label={text.unlike} data-ready-message={text.countersReady} data-error-message={text.counterError} data-mock-result={mockResult}>
|
||||||
<span aria-label={text.articleViews}><span class="view-count" data-views dir={lang === 'ar' ? 'ltr' : undefined}>—</span> {text.views}</span>
|
<span aria-label={text.articleViews}><span class="view-count" data-views dir={lang === 'ar' ? 'ltr' : undefined}>—</span> {text.views}</span>
|
||||||
<span class="like-group" role={lang === 'ar' ? 'group' : undefined} aria-label={lang === 'ar' ? text.likes : undefined}>
|
<span class="like-group" role={lang === 'ar' ? 'group' : undefined} aria-label={lang === 'ar' ? text.likes : undefined}>
|
||||||
<span class="like-count" data-likes dir={lang === 'ar' ? 'ltr' : undefined}>—</span>
|
<span class="like-count" data-likes dir={lang === 'ar' ? 'ltr' : undefined}>—</span>
|
||||||
<button class="like-button" type="button" data-like data-slug={slug} aria-label={text.like} aria-describedby={statusId} aria-pressed="false" disabled={runtimeMode === 'off'}>
|
<button class="like-button" type="button" data-like aria-label={text.like} aria-describedby={statusId} aria-pressed="false" disabled={runtimeMode !== 'mock'}>
|
||||||
<svg aria-hidden="true" viewBox="0 0 12 10" width="20" height="20" focusable="false" class="like-icon">
|
<svg aria-hidden="true" viewBox="0 0 12 10" width="20" height="20" focusable="false" class="like-icon">
|
||||||
<path d="m1 4.4c-1-4 2.5-5 4.5-2.5l.5 .6l.5-.6c2-2.5 6-1.5 4.5 2.5q-1 3-5 5q-4-2-5-5z"></path>
|
<path d="m1 4.4c-1-4 2.5-5 4.5-2.5l.5 .6l.5-.6c2-2.5 6-1.5 4.5 2.5q-1 3-5 5q-4-2-5-5z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
<span id={statusId} class="counter-status" aria-live="polite">{runtimeMode === 'mock' ? 'Demo metrics only' : text.counterUnavailable}</span>
|
<span id={statusId} class="counter-status" aria-live="polite">{runtimeMode === 'mock' ? 'Demo metrics only' : runtimeMode === 'off' ? text.counterUnavailable : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
import { bindMockForms } from '../scripts/mock-form';
|
import { bindMockForms } from '../scripts/mock-form';
|
||||||
|
import { bindLiveCounters } from '../scripts/likes-views';
|
||||||
bindMockForms();
|
bindMockForms();
|
||||||
|
void bindLiveCounters();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const schema = blogPostingSchema(entry, publication, authors, site.url);
|
|||||||
{entry.data.featuredImage && <div class="article-featured"><img src={entry.data.featuredImage} alt={entry.data.alt || ''} /></div>}
|
{entry.data.featuredImage && <div class="article-featured"><img src={entry.data.featuredImage} alt={entry.data.alt || ''} /></div>}
|
||||||
</header>
|
</header>
|
||||||
<div class="prose blog-post-body container"><slot /></div>
|
<div class="prose blog-post-body container"><slot /></div>
|
||||||
<div class="container"><LikeViewCounter slug={entry.data.slug} lang={entry.data.lang} /></div>
|
<div class="container"><LikeViewCounter slug={entry.data.slug} lang={entry.data.lang} trackView /></div>
|
||||||
</article>
|
</article>
|
||||||
<BlogPostFooter entryId={entry.id} lang={entry.data.lang} />
|
<BlogPostFooter entryId={entry.id} lang={entry.data.lang} />
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|||||||
@@ -1,2 +1,203 @@
|
|||||||
/** Live counter behavior is intentionally not enabled by this migration. */
|
const likeStorageKey = 'aia-liked-articles-v1';
|
||||||
export { counterModeForRuntime, resolveCounterConfig } from '../utils/counter-mode';
|
const requestTimeoutMs = 8_000;
|
||||||
|
const pendingLikes = new Set<string>();
|
||||||
|
|
||||||
|
type CounterStats = Record<string, { likes: number; views: number }>;
|
||||||
|
type Fetcher = typeof fetch;
|
||||||
|
|
||||||
|
function endpoint(base: string, path: string) {
|
||||||
|
return `${base.replace(/\/$/, '')}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function browserStorage(): Pick<Storage, 'getItem' | 'setItem'> {
|
||||||
|
try {
|
||||||
|
return localStorage;
|
||||||
|
} catch {
|
||||||
|
return { getItem: () => null, setItem: () => undefined };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readLikedSlugs(storage: Pick<Storage, 'getItem'> = browserStorage()) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(storage.getItem(likeStorageKey) ?? '[]');
|
||||||
|
return new Set(Array.isArray(parsed) ? parsed.filter((slug): slug is string => typeof slug === 'string') : []);
|
||||||
|
} catch {
|
||||||
|
return new Set<string>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeLikedSlugs(liked: Set<string>, storage: Pick<Storage, 'setItem'>) {
|
||||||
|
try {
|
||||||
|
storage.setItem(likeStorageKey, JSON.stringify([...liked].sort()));
|
||||||
|
} catch {
|
||||||
|
// The counter remains usable in memory when storage is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson(fetcher: Fetcher, url: string, init: RequestInit, timeoutMs = requestTimeoutMs) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = globalThis.setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const response = await fetcher(url, {
|
||||||
|
...init,
|
||||||
|
signal: controller.signal,
|
||||||
|
credentials: 'omit',
|
||||||
|
referrerPolicy: 'no-referrer',
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`Counter API returned ${response.status}`);
|
||||||
|
return await response.json();
|
||||||
|
} finally {
|
||||||
|
globalThis.clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCounterStats(apiBase: string, slugs: string[], fetcher: Fetcher = fetch, timeoutMs = requestTimeoutMs): Promise<CounterStats> {
|
||||||
|
return requestJson(fetcher, endpoint(apiBase, '/stats/batch'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ slugs: [...new Set(slugs)] }),
|
||||||
|
}, timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reportArticleView(apiBase: string, slug: string, fetcher: Fetcher = fetch): Promise<number> {
|
||||||
|
const result = await requestJson(fetcher, endpoint(apiBase, '/views'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ slug }),
|
||||||
|
keepalive: true,
|
||||||
|
});
|
||||||
|
if (!Number.isInteger(result.views) || result.views < 0) throw new Error('Invalid view response');
|
||||||
|
return result.views;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateLike(apiBase: string, slug: string, liked: boolean, fetcher: Fetcher = fetch): Promise<number> {
|
||||||
|
const result = await requestJson(fetcher, endpoint(apiBase, liked ? `/likes/${encodeURIComponent(slug)}` : '/likes'), {
|
||||||
|
method: liked ? 'DELETE' : 'POST',
|
||||||
|
headers: liked ? undefined : { 'Content-Type': 'application/json' },
|
||||||
|
body: liked ? undefined : JSON.stringify({ slug }),
|
||||||
|
});
|
||||||
|
if (!Number.isInteger(result.count) || result.count < 0) throw new Error('Invalid like response');
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countersForSlug(root: ParentNode, slug: string) {
|
||||||
|
return [...root.querySelectorAll<HTMLElement>('[data-counter][data-counter-mode="live"]')]
|
||||||
|
.filter((counter) => counter.dataset.slug === slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(root: ParentNode, slug: string, stats: { likes?: number; views?: number }) {
|
||||||
|
for (const counter of countersForSlug(root, slug)) {
|
||||||
|
if (stats.views !== undefined) {
|
||||||
|
const views = counter.querySelector<HTMLElement>('[data-views]');
|
||||||
|
if (views) views.textContent = String(stats.views);
|
||||||
|
}
|
||||||
|
if (stats.likes !== undefined) {
|
||||||
|
const likes = counter.querySelector<HTMLElement>('[data-likes]');
|
||||||
|
if (likes) likes.textContent = String(stats.likes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLiked(root: ParentNode, slug: string, liked: boolean) {
|
||||||
|
for (const counter of countersForSlug(root, slug)) {
|
||||||
|
const button = counter.querySelector<HTMLButtonElement>('[data-like]');
|
||||||
|
if (!button) continue;
|
||||||
|
button.setAttribute('aria-pressed', String(liked));
|
||||||
|
button.classList.toggle('liked', liked);
|
||||||
|
button.setAttribute('aria-label', liked ? counter.dataset.unlikeLabel ?? 'Remove your like' : counter.dataset.likeLabel ?? 'Like this article');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function status(root: ParentNode, slug: string, failed: boolean) {
|
||||||
|
for (const counter of countersForSlug(root, slug)) {
|
||||||
|
const output = counter.querySelector<HTMLElement>('.counter-status');
|
||||||
|
if (output) output.textContent = failed ? counter.dataset.errorMessage ?? 'Engagement metrics are temporarily unavailable' : counter.dataset.readyMessage ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bindLiveCounters(
|
||||||
|
root: ParentNode = document,
|
||||||
|
fetcher: Fetcher = fetch,
|
||||||
|
storage: Pick<Storage, 'getItem' | 'setItem'> = browserStorage(),
|
||||||
|
) {
|
||||||
|
const counters = [...root.querySelectorAll<HTMLElement>('[data-counter][data-counter-mode="live"]')];
|
||||||
|
const pending = counters.filter((counter) => counter.dataset.counterBound !== 'true');
|
||||||
|
if (!pending.length) return;
|
||||||
|
for (const counter of pending) counter.dataset.counterBound = 'true';
|
||||||
|
|
||||||
|
const liked = readLikedSlugs(storage);
|
||||||
|
const apiBase = pending[0].dataset.apiBase;
|
||||||
|
const slugs = [...new Set(pending.map((counter) => counter.dataset.slug).filter((slug): slug is string => Boolean(slug)))];
|
||||||
|
if (!apiBase || !slugs.length) return;
|
||||||
|
|
||||||
|
for (const slug of slugs) renderLiked(root, slug, liked.has(slug));
|
||||||
|
const viewRequests = pending
|
||||||
|
.filter((counter) => counter.dataset.trackView === 'true' && counter.dataset.viewReported !== 'true')
|
||||||
|
.map((counter) => {
|
||||||
|
const slug = counter.dataset.slug;
|
||||||
|
if (!slug) return undefined;
|
||||||
|
// Mark before the non-retried request. Repeated initialization and
|
||||||
|
// bfcache restoration reuse this DOM marker and cannot double-count.
|
||||||
|
counter.dataset.viewReported = 'true';
|
||||||
|
return reportArticleView(apiBase, slug, fetcher)
|
||||||
|
.then((views) => ({ slug, views, failed: false }))
|
||||||
|
.catch(() => ({ slug, views: undefined, failed: true }));
|
||||||
|
})
|
||||||
|
.filter((request): request is NonNullable<typeof request> => Boolean(request));
|
||||||
|
|
||||||
|
// Start the pageview request immediately. Statistics retrieval and rendering
|
||||||
|
// remain nonblocking, and the later view response wins any read/increment race.
|
||||||
|
try {
|
||||||
|
const stats = await fetchCounterStats(apiBase, slugs, fetcher);
|
||||||
|
for (const slug of slugs) {
|
||||||
|
if (stats[slug]) render(root, slug, stats[slug]);
|
||||||
|
status(root, slug, false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
for (const slug of slugs) status(root, slug, true);
|
||||||
|
} finally {
|
||||||
|
for (const counter of pending) {
|
||||||
|
const button = counter.querySelector<HTMLButtonElement>('[data-like]');
|
||||||
|
const slug = counter.dataset.slug;
|
||||||
|
if (!button || !slug) continue;
|
||||||
|
button.disabled = false;
|
||||||
|
button.addEventListener('click', async () => {
|
||||||
|
if (pendingLikes.has(slug)) return;
|
||||||
|
pendingLikes.add(slug);
|
||||||
|
for (const copy of countersForSlug(root, slug)) {
|
||||||
|
const copyButton = copy.querySelector<HTMLButtonElement>('[data-like]');
|
||||||
|
if (copyButton) copyButton.disabled = true;
|
||||||
|
}
|
||||||
|
const wasLiked = liked.has(slug);
|
||||||
|
try {
|
||||||
|
const count = await updateLike(apiBase, slug, wasLiked, fetcher);
|
||||||
|
wasLiked ? liked.delete(slug) : liked.add(slug);
|
||||||
|
writeLikedSlugs(liked, storage);
|
||||||
|
render(root, slug, { likes: count });
|
||||||
|
renderLiked(root, slug, !wasLiked);
|
||||||
|
status(root, slug, false);
|
||||||
|
} catch {
|
||||||
|
status(root, slug, true);
|
||||||
|
} finally {
|
||||||
|
pendingLikes.delete(slug);
|
||||||
|
for (const copy of countersForSlug(root, slug)) {
|
||||||
|
const copyButton = copy.querySelector<HTMLButtonElement>('[data-like]');
|
||||||
|
if (copyButton) copyButton.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const request of viewRequests) {
|
||||||
|
const result = await request;
|
||||||
|
if (result.failed || result.views === undefined) status(root, result.slug, true);
|
||||||
|
else render(root, result.slug, { views: result.views });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.addEventListener('astro:page-load', () => void bindLiveCounters());
|
||||||
|
}
|
||||||
|
|
||||||
|
export { counterModeForRuntime, resolveCounterConfig } from '../utils/counter-mode.ts';
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
/** Arabic article chrome; preserve the existing English/Spanish labels. */
|
/** Arabic article chrome; preserve the existing English/Spanish labels. */
|
||||||
const english = {
|
const english = {
|
||||||
views: 'views', articleViews: 'Article views', likes: 'Likes', like: 'Like this article',
|
views: 'views', articleViews: 'Article views', likes: 'Likes', like: 'Like this article',
|
||||||
|
unlike: 'Remove your like', countersReady: '', counterError: 'Engagement metrics are temporarily unavailable',
|
||||||
counterUnavailable: 'Engagement metrics are unavailable',
|
counterUnavailable: 'Engagement metrics are unavailable',
|
||||||
navigation: 'Article navigation', previous: 'Previous Post', next: 'Next Post',
|
navigation: 'Article navigation', previous: 'Previous Post', next: 'Next Post',
|
||||||
related: 'Similar Blog Posts', readFull: 'Read full post',
|
related: 'Similar Blog Posts', readFull: 'Read full post',
|
||||||
};
|
};
|
||||||
const arabic: typeof english = {
|
const arabic: typeof english = {
|
||||||
views: 'مشاهدات', articleViews: 'مشاهدات المقال', likes: 'الإعجابات', like: 'الإعجاب بهذا المقال',
|
views: 'مشاهدات', articleViews: 'مشاهدات المقال', likes: 'الإعجابات', like: 'الإعجاب بهذا المقال',
|
||||||
|
unlike: 'إزالة الإعجاب', countersReady: '', counterError: 'إحصاءات التفاعل غير متاحة مؤقتًا',
|
||||||
counterUnavailable: 'إحصاءات التفاعل غير متاحة',
|
counterUnavailable: 'إحصاءات التفاعل غير متاحة',
|
||||||
navigation: 'التنقل بين المقالات', previous: 'المقال السابق', next: 'المقال التالي',
|
navigation: 'التنقل بين المقالات', previous: 'المقال السابق', next: 'المقال التالي',
|
||||||
related: 'مقالات ذات صلة', readFull: 'قراءة المقال كاملاً',
|
related: 'مقالات ذات صلة', readFull: 'قراءة المقال كاملاً',
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ export function resolveCounterConfig(
|
|||||||
return { mode: 'live', apiBase, reason: 'live-approved' };
|
return { mode: 'live', apiBase, reason: 'live-approved' };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function counterModeForRuntime(config: CounterConfig): 'off' | 'mock' {
|
export function counterModeForRuntime(config: CounterConfig): CounterMode {
|
||||||
// Live behavior is intentionally not implemented by the migration. Keep an
|
return config.mode;
|
||||||
// approved future setting fail-closed until a separately authorized adapter exists.
|
|
||||||
return config.mode === 'mock' ? 'mock' : 'off';
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* Browser regression for the built live-counter artifact. Every API request is
|
||||||
|
* intercepted and fulfilled locally; this script never writes production data.
|
||||||
|
*/
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import http from 'node:http';
|
||||||
|
import net from 'node:net';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
import { setTimeout as delay } from 'node:timers/promises';
|
||||||
|
|
||||||
|
const browserName = process.argv.find((argument) => argument.startsWith('--browser='))?.split('=')[1] ?? 'chromium';
|
||||||
|
assert.ok(['chromium', 'firefox'].includes(browserName));
|
||||||
|
const moduleName = process.env.PLAYWRIGHT_MODULE
|
||||||
|
? pathToFileURL(path.resolve(process.env.PLAYWRIGHT_MODULE)).href
|
||||||
|
: 'playwright';
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const reserve = net.createServer();
|
||||||
|
await new Promise((resolve, reject) => { reserve.once('error', reject); reserve.listen(0, '127.0.0.1', resolve); });
|
||||||
|
const port = reserve.address().port;
|
||||||
|
await new Promise((resolve) => reserve.close(resolve));
|
||||||
|
const origin = `http://127.0.0.1:${port}`;
|
||||||
|
const apiOrigin = 'https://api.azinstitute4autism.com';
|
||||||
|
const calls = [];
|
||||||
|
let views = 100;
|
||||||
|
let likes = 5;
|
||||||
|
let browser;
|
||||||
|
let preview;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const drivers = await import(moduleName);
|
||||||
|
const driver = drivers[browserName];
|
||||||
|
const executablePath = browserName === 'chromium' ? process.env.CHROMIUM_EXECUTABLE : process.env.FIREFOX_EXECUTABLE;
|
||||||
|
browser = await driver.launch(executablePath ? { headless: true, executablePath } : { headless: true });
|
||||||
|
preview = spawn(process.execPath, ['node_modules/astro/bin/astro.mjs', 'preview', '--host', '127.0.0.1', '--port', String(port)], {
|
||||||
|
cwd: root,
|
||||||
|
env: { ...process.env, ASTRO_TELEMETRY_DISABLED: '1' },
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
|
const reachable = () => new Promise((resolve) => {
|
||||||
|
const request = http.get(`${origin}/`, (response) => { response.resume(); resolve(response.statusCode === 200); });
|
||||||
|
request.setTimeout(500, () => request.destroy());
|
||||||
|
request.on('error', () => resolve(false));
|
||||||
|
});
|
||||||
|
for (let attempt = 0; attempt < 60 && !(await reachable()); attempt += 1) await delay(250);
|
||||||
|
assert.equal(await reachable(), true, 'Loopback Astro preview did not start');
|
||||||
|
|
||||||
|
const context = await browser.newContext();
|
||||||
|
await context.route(`${apiOrigin}/**`, async (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
const url = new URL(request.url());
|
||||||
|
if (request.method() === 'OPTIONS') {
|
||||||
|
await route.fulfill({ status: 204, headers: {
|
||||||
|
'access-control-allow-origin': origin,
|
||||||
|
'access-control-allow-methods': 'POST, DELETE, OPTIONS',
|
||||||
|
'access-control-allow-headers': 'content-type',
|
||||||
|
} });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = request.postDataJSON?.() ?? undefined;
|
||||||
|
calls.push({ method: request.method(), path: url.pathname, body });
|
||||||
|
let response;
|
||||||
|
if (url.pathname === '/stats/batch') {
|
||||||
|
response = Object.fromEntries(body.slugs.map((slug) => [slug, { likes, views }]));
|
||||||
|
} else if (url.pathname === '/views') {
|
||||||
|
views += 1;
|
||||||
|
response = { views };
|
||||||
|
} else if (url.pathname === '/likes' && request.method() === 'POST') {
|
||||||
|
likes += 1;
|
||||||
|
response = { count: likes };
|
||||||
|
} else if (url.pathname.startsWith('/likes/') && request.method() === 'DELETE') {
|
||||||
|
likes = Math.max(0, likes - 1);
|
||||||
|
response = { count: likes };
|
||||||
|
} else {
|
||||||
|
await route.fulfill({ status: 404, body: '{}' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
headers: { 'access-control-allow-origin': origin },
|
||||||
|
body: JSON.stringify(response),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
const article = '/library/aba-school-readiness-guide';
|
||||||
|
await page.goto(`${origin}${article}`);
|
||||||
|
await page.locator('[data-counter][data-track-view="true"] [data-views]').waitFor();
|
||||||
|
await assert.doesNotReject(async () => page.waitForFunction(() =>
|
||||||
|
document.querySelector('[data-counter][data-track-view="true"] [data-views]')?.textContent === '101'));
|
||||||
|
assert.equal(calls.filter((call) => call.path === '/views').length, 1);
|
||||||
|
assert.deepEqual(calls.find((call) => call.path === '/views').body, { slug: 'aba-school-readiness-guide' });
|
||||||
|
|
||||||
|
await page.locator('[data-counter][data-track-view="true"] [data-like]').click();
|
||||||
|
await page.waitForFunction(() => document.querySelector('[data-counter][data-track-view="true"] [data-like]')?.getAttribute('aria-pressed') === 'true');
|
||||||
|
assert.equal(calls.filter((call) => call.path === '/likes' && call.method === 'POST').length, 1);
|
||||||
|
assert.equal(await page.evaluate(() => localStorage.length), 1);
|
||||||
|
assert.deepEqual(await page.evaluate(() => Object.keys(localStorage)), ['aia-liked-articles-v1']);
|
||||||
|
|
||||||
|
await page.locator('[data-counter][data-track-view="true"] [data-like]').click();
|
||||||
|
await page.waitForFunction(() => document.querySelector('[data-counter][data-track-view="true"] [data-like]')?.getAttribute('aria-pressed') === 'false');
|
||||||
|
assert.equal(calls.filter((call) => call.method === 'DELETE').length, 1);
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForFunction(() => document.querySelector('[data-counter][data-track-view="true"] [data-views]')?.textContent === '102');
|
||||||
|
assert.equal(calls.filter((call) => call.path === '/views').length, 2, 'a real reload is a new view');
|
||||||
|
|
||||||
|
const beforeListing = calls.filter((call) => call.path === '/views').length;
|
||||||
|
await page.goto(`${origin}/library`);
|
||||||
|
await page.waitForFunction(() => document.querySelector('[data-counter] [data-views]')?.textContent !== '—');
|
||||||
|
assert.equal(calls.filter((call) => call.path === '/views').length, beforeListing, 'listing cards never report views');
|
||||||
|
|
||||||
|
for (const route of [
|
||||||
|
'/es/library/executive-functioning-skills-autism',
|
||||||
|
'/ar/library/executive-functioning-skills-autism',
|
||||||
|
]) {
|
||||||
|
await page.goto(`${origin}${route}`);
|
||||||
|
await page.waitForFunction(() => document.querySelector('[data-counter][data-track-view="true"]')?.dataset.viewReported === 'true');
|
||||||
|
}
|
||||||
|
const translated = calls.filter((call) => call.path === '/views').slice(-2);
|
||||||
|
assert.deepEqual(translated.map((call) => call.body.slug), [
|
||||||
|
'executive-functioning-skills-autism',
|
||||||
|
'executive-functioning-skills-autism',
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log(JSON.stringify({ browser: browserName, version: browser.version(), checks: 18, productionRequests: 0 }));
|
||||||
|
await context.close();
|
||||||
|
} finally {
|
||||||
|
if (preview) preview.kill('SIGTERM');
|
||||||
|
if (browser) await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import fg from 'fast-glob';
|
||||||
|
import matter from 'gray-matter';
|
||||||
|
import {
|
||||||
|
bindLiveCounters,
|
||||||
|
fetchCounterStats,
|
||||||
|
readLikedSlugs,
|
||||||
|
reportArticleView,
|
||||||
|
updateLike,
|
||||||
|
} from '../src/scripts/likes-views.ts';
|
||||||
|
|
||||||
|
class FakeClassList {
|
||||||
|
values = new Set();
|
||||||
|
toggle(name, enabled) { enabled ? this.values.add(name) : this.values.delete(name); }
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeButton {
|
||||||
|
attributes = new Map([['aria-pressed', 'false']]);
|
||||||
|
classList = new FakeClassList();
|
||||||
|
disabled = true;
|
||||||
|
listeners = [];
|
||||||
|
setAttribute(name, value) { this.attributes.set(name, value); }
|
||||||
|
getAttribute(name) { return this.attributes.get(name); }
|
||||||
|
addEventListener(name, callback) { if (name === 'click') this.listeners.push(callback); }
|
||||||
|
async click() { for (const callback of this.listeners) await callback(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeCounter {
|
||||||
|
constructor(slug, { trackView = false, lang = 'en' } = {}) {
|
||||||
|
this.dataset = {
|
||||||
|
slug,
|
||||||
|
counterMode: 'live',
|
||||||
|
apiBase: 'https://api.example.test',
|
||||||
|
trackView: String(trackView),
|
||||||
|
likeLabel: lang === 'ar' ? 'إعجاب' : 'Like',
|
||||||
|
unlikeLabel: lang === 'ar' ? 'إزالة الإعجاب' : 'Unlike',
|
||||||
|
readyMessage: '',
|
||||||
|
errorMessage: lang === 'ar' ? 'خطأ مؤقت' : 'Temporarily unavailable',
|
||||||
|
};
|
||||||
|
this.views = { textContent: '—' };
|
||||||
|
this.likes = { textContent: '—' };
|
||||||
|
this.status = { textContent: '' };
|
||||||
|
this.button = new FakeButton();
|
||||||
|
}
|
||||||
|
querySelector(selector) {
|
||||||
|
return selector === '[data-views]' ? this.views
|
||||||
|
: selector === '[data-likes]' ? this.likes
|
||||||
|
: selector === '[data-like]' ? this.button
|
||||||
|
: selector === '.counter-status' ? this.status : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeRoot {
|
||||||
|
constructor(counters) { this.counters = counters; }
|
||||||
|
querySelectorAll(selector) {
|
||||||
|
return selector === '[data-counter][data-counter-mode="live"]' ? this.counters : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeStorage {
|
||||||
|
values = new Map();
|
||||||
|
getItem(key) { return this.values.get(key) ?? null; }
|
||||||
|
setItem(key, value) { this.values.set(key, value); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function response(body, status = 200) {
|
||||||
|
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('counter transport batches unique slugs and never sends credentials', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const fetcher = async (url, options) => {
|
||||||
|
calls.push({ url, options });
|
||||||
|
return response({ article: { likes: 3, views: 9 } });
|
||||||
|
};
|
||||||
|
const result = await fetchCounterStats('https://api.example.test/', ['article', 'article'], fetcher);
|
||||||
|
assert.equal(result.article.views, 9);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0].url, 'https://api.example.test/stats/batch');
|
||||||
|
assert.equal(calls[0].options.credentials, 'omit');
|
||||||
|
assert.equal(calls[0].options.referrerPolicy, 'no-referrer');
|
||||||
|
assert.deepEqual(JSON.parse(calls[0].options.body), { slugs: ['article'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('article views and likes use the existing API contracts', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const fetcher = async (url, options) => {
|
||||||
|
calls.push({ url, options });
|
||||||
|
return response(url.endsWith('/views') ? { views: 42 } : { count: 8 });
|
||||||
|
};
|
||||||
|
assert.equal(await reportArticleView('https://api.example.test', 'article', fetcher), 42);
|
||||||
|
assert.equal(await updateLike('https://api.example.test', 'article', false, fetcher), 8);
|
||||||
|
assert.equal(await updateLike('https://api.example.test', 'article', true, fetcher), 8);
|
||||||
|
assert.deepEqual(JSON.parse(calls[0].options.body), { slug: 'article' });
|
||||||
|
assert.equal(calls[0].options.keepalive, true);
|
||||||
|
assert.equal(calls[1].options.method, 'POST');
|
||||||
|
assert.equal(calls[2].options.method, 'DELETE');
|
||||||
|
assert.equal(calls[2].url, 'https://api.example.test/likes/article');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one article navigation increments once while cards only retrieve statistics', async () => {
|
||||||
|
const article = new FakeCounter('shared-slug', { trackView: true });
|
||||||
|
const duplicate = new FakeCounter('shared-slug');
|
||||||
|
const card = new FakeCounter('other-slug');
|
||||||
|
const root = new FakeRoot([article, duplicate, card]);
|
||||||
|
const storage = new FakeStorage();
|
||||||
|
const calls = [];
|
||||||
|
const fetcher = async (url, options) => {
|
||||||
|
calls.push({ url, options });
|
||||||
|
if (url.endsWith('/stats/batch')) return response({
|
||||||
|
'shared-slug': { likes: 4, views: 20 },
|
||||||
|
'other-slug': { likes: 1, views: 2 },
|
||||||
|
});
|
||||||
|
if (url.endsWith('/views')) return response({ views: 21 });
|
||||||
|
if (options.method === 'POST') return response({ count: 5 });
|
||||||
|
return response({ count: 4 });
|
||||||
|
};
|
||||||
|
|
||||||
|
await bindLiveCounters(root, fetcher, storage);
|
||||||
|
await bindLiveCounters(root, fetcher, storage);
|
||||||
|
assert.equal(calls.filter((call) => call.url.endsWith('/stats/batch')).length, 1);
|
||||||
|
assert.equal(calls.filter((call) => call.url.endsWith('/views')).length, 1);
|
||||||
|
assert.equal(article.views.textContent, '21');
|
||||||
|
assert.equal(duplicate.views.textContent, '21');
|
||||||
|
assert.equal(card.views.textContent, '2');
|
||||||
|
|
||||||
|
await article.button.click();
|
||||||
|
assert.equal(article.likes.textContent, '5');
|
||||||
|
assert.equal(duplicate.likes.textContent, '5');
|
||||||
|
assert.equal(article.button.getAttribute('aria-pressed'), 'true');
|
||||||
|
assert.deepEqual([...readLikedSlugs(storage)], ['shared-slug']);
|
||||||
|
|
||||||
|
await duplicate.button.click();
|
||||||
|
assert.equal(article.likes.textContent, '4');
|
||||||
|
assert.equal(duplicate.button.getAttribute('aria-pressed'), 'false');
|
||||||
|
assert.deepEqual([...readLikedSlugs(storage)], []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('API failures preserve rendering and expose localized inline status', async () => {
|
||||||
|
const counter = new FakeCounter('article', { trackView: true, lang: 'ar' });
|
||||||
|
const root = new FakeRoot([counter]);
|
||||||
|
await bindLiveCounters(root, async () => response({ error: 'offline' }, 503), new FakeStorage());
|
||||||
|
assert.equal(counter.status.textContent, 'خطأ مؤقت');
|
||||||
|
assert.equal(counter.button.disabled, false);
|
||||||
|
assert.equal(counter.dataset.viewReported, 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('like persistence rejects corrupt and non-array storage values', () => {
|
||||||
|
const storage = new FakeStorage();
|
||||||
|
storage.setItem('aia-liked-articles-v1', '{broken');
|
||||||
|
assert.deepEqual([...readLikedSlugs(storage)], []);
|
||||||
|
storage.setItem('aia-liked-articles-v1', JSON.stringify(['valid', 4, null]));
|
||||||
|
assert.deepEqual([...readLikedSlugs(storage)], ['valid']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('blocked local storage does not duplicate or disable a successful like', async () => {
|
||||||
|
const counter = new FakeCounter('article');
|
||||||
|
const root = new FakeRoot([counter]);
|
||||||
|
const storage = {
|
||||||
|
getItem() { throw new Error('blocked'); },
|
||||||
|
setItem() { throw new Error('blocked'); },
|
||||||
|
};
|
||||||
|
let writes = 0;
|
||||||
|
const fetcher = async (url) => {
|
||||||
|
if (url.endsWith('/stats/batch')) return response({ article: { likes: 2, views: 3 } });
|
||||||
|
writes += 1;
|
||||||
|
return response({ count: 3 });
|
||||||
|
};
|
||||||
|
await bindLiveCounters(root, fetcher, storage);
|
||||||
|
await counter.button.click();
|
||||||
|
assert.equal(writes, 1);
|
||||||
|
assert.equal(counter.likes.textContent, '3');
|
||||||
|
assert.equal(counter.button.getAttribute('aria-pressed'), 'true');
|
||||||
|
assert.equal(counter.button.disabled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('counter requests time out without retrying an ambiguous operation', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const hanging = (_url, options) => {
|
||||||
|
calls += 1;
|
||||||
|
return new Promise((_resolve, reject) => options.signal.addEventListener('abort', () => reject(options.signal.reason)));
|
||||||
|
};
|
||||||
|
await assert.rejects(fetchCounterStats('https://api.example.test', ['article'], hanging, 5));
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only article layouts track views and translated publications retain exact legacy slugs', async () => {
|
||||||
|
const layout = await fs.readFile('src/layouts/BlogPostLayout.astro', 'utf8');
|
||||||
|
const card = await fs.readFile('src/components/BlogCard.astro', 'utf8');
|
||||||
|
assert.match(layout, /LikeViewCounter[^>]*trackView/);
|
||||||
|
assert.doesNotMatch(card, /trackView/);
|
||||||
|
|
||||||
|
const groups = new Map();
|
||||||
|
for (const file of await fg('src/content/blog/**/*.{md,mdx}')) {
|
||||||
|
const frontmatter = matter(await fs.readFile(file, 'utf8')).data;
|
||||||
|
if (!frontmatter.translationKey || frontmatter.draft) continue;
|
||||||
|
const entries = groups.get(frontmatter.translationKey) ?? [];
|
||||||
|
entries.push({ lang: frontmatter.lang, slug: frontmatter.slug, file: path.basename(file) });
|
||||||
|
groups.set(frontmatter.translationKey, entries);
|
||||||
|
}
|
||||||
|
const multilingual = [...groups.values()].filter((entries) => entries.length > 1);
|
||||||
|
assert.ok(multilingual.some((entries) => new Set(entries.map(({ lang }) => lang)).size === 3));
|
||||||
|
for (const entries of multilingual) {
|
||||||
|
assert.equal(new Set(entries.map(({ slug }) => slug)).size, 1, JSON.stringify(entries));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -142,7 +142,7 @@ function validationEnvironment() {
|
|||||||
async function executeBuild(buildEnv) {
|
async function executeBuild(buildEnv) {
|
||||||
const testEnv = validationEnvironment();
|
const testEnv = validationEnvironment();
|
||||||
run('npm', ['run', 'check'], { env: testEnv });
|
run('npm', ['run', 'check'], { env: testEnv });
|
||||||
run('node', ['--experimental-strip-types', '--test', 'tools/jotform-form-a.test.mjs', 'tools/service-portal.test.mjs', 'tools/phase10-artifact.test.mjs'], { env: testEnv });
|
run('node', ['--experimental-strip-types', '--test', 'tools/jotform-form-a.test.mjs', 'tools/service-portal.test.mjs', 'tools/phase10-artifact.test.mjs', 'tools/counters.test.mjs'], { env: testEnv });
|
||||||
run('npm', ['run', 'test:publication'], { env: testEnv });
|
run('npm', ['run', 'test:publication'], { env: testEnv });
|
||||||
run('npm', ['run', 'audit:links'], { env: testEnv });
|
run('npm', ['run', 'audit:links'], { env: testEnv });
|
||||||
run('npm', ['run', 'audit:images'], { env: testEnv });
|
run('npm', ['run', 'audit:images'], { env: testEnv });
|
||||||
@@ -187,10 +187,10 @@ async function main() {
|
|||||||
} else portal = await syntheticPayload(temporary);
|
} else portal = await syntheticPayload(temporary);
|
||||||
if (!options.dryRun && portal.synthetic) fail('A real deployment requires AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE');
|
if (!options.dryRun && portal.synthetic) fail('A real deployment requires AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE');
|
||||||
if (!options.dryRun && !options.confirm) fail('Refusing to upload without --confirm; use --dry-run for local verification');
|
if (!options.dryRun && !options.confirm) fail('Refusing to upload without --confirm; use --dry-run for local verification');
|
||||||
const env = { ...process.env, PUBLIC_ALLOW_INDEXING: 'true', PUBLIC_AIA_FORMS_MODE: 'live', PUBLIC_COUNTERS_MODE: 'off', AIA_PORTAL_PRODUCTION_APPROVED: 'true', AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE: portal.file, AIA_PORTAL_LOCAL_PREVIEW_APPROVED: 'false' };
|
const env = { ...process.env, PUBLIC_ALLOW_INDEXING: 'true', PUBLIC_AIA_FORMS_MODE: 'live', PUBLIC_COUNTERS_MODE: 'live', PUBLIC_AIA_API_BASE: 'https://api.azinstitute4autism.com', PUBLIC_COUNTERS_LIVE_APPROVED: 'true', AIA_PORTAL_PRODUCTION_APPROVED: 'true', AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE: portal.file, AIA_PORTAL_LOCAL_PREVIEW_APPROVED: 'false' };
|
||||||
await executeBuild(env);
|
await executeBuild(env);
|
||||||
const portalSha256 = sha256(Buffer.from(JSON.stringify(portal.payload)));
|
const portalSha256 = sha256(Buffer.from(JSON.stringify(portal.payload)));
|
||||||
const artifact = await verifyArtifact(path.join(toolRoot, 'dist'), 'indexing', 'live', { mode: portal.synthetic ? 'synthetic' : 'production', sha256: portal.synthetic ? undefined : portalSha256 });
|
const artifact = await verifyArtifact(path.join(toolRoot, 'dist'), 'indexing', 'live', { mode: portal.synthetic ? 'synthetic' : 'production', sha256: portal.synthetic ? undefined : portalSha256 }, 'live');
|
||||||
if (!artifact.passed) fail(`Artifact verification failed: ${artifact.errors.join('; ')}`);
|
if (!artifact.passed) fail(`Artifact verification failed: ${artifact.errors.join('; ')}`);
|
||||||
const manifest = await releaseManifest(path.join(toolRoot, 'dist'), sha);
|
const manifest = await releaseManifest(path.join(toolRoot, 'dist'), sha);
|
||||||
const manifestFile = path.join(temporary, `aia-site-${sha}.manifest.json`);
|
const manifestFile = path.join(temporary, `aia-site-${sha}.manifest.json`);
|
||||||
|
|||||||
@@ -85,20 +85,25 @@ function formAAdapters(html) {
|
|||||||
return [...html.matchAll(/<section\b[^>]*\bdata-jotform-form-a\b[^>]*>/gi)].map((match) => match[0]);
|
return [...html.matchAll(/<section\b[^>]*\bdata-jotform-form-a\b[^>]*>/gi)].map((match) => match[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function unsafeSignals(html, allowApprovedFormA = false) {
|
function unsafeSignals(html, allowApprovedFormA = false, allowApprovedCounters = false) {
|
||||||
const signals = [];
|
const signals = [];
|
||||||
if (!allowApprovedFormA && /<iframe\b[^>]*\bsrc=["'][^"']*jotform/i.test(html)) signals.push('active Jotform iframe');
|
if (!allowApprovedFormA && /<iframe\b[^>]*\bsrc=["'][^"']*jotform/i.test(html)) signals.push('active Jotform iframe');
|
||||||
if (!allowApprovedFormA && /262946078314059/i.test(html)) signals.push('disabled Form A identifier');
|
if (!allowApprovedFormA && /262946078314059/i.test(html)) signals.push('disabled Form A identifier');
|
||||||
if (/<script\b[^>]*\bsrc=["'][^"']*(?:jotform|googletagmanager|google-analytics|doubleclick)/i.test(html)) signals.push('active vendor tracking/form script');
|
if (/<script\b[^>]*\bsrc=["'][^"']*(?:jotform|googletagmanager|google-analytics|doubleclick)/i.test(html)) signals.push('active vendor tracking/form script');
|
||||||
if (!allowApprovedFormA && /data-(?:form|forms)-mode=["']live["']/i.test(html)) signals.push('live form mode');
|
if (!allowApprovedFormA && /data-(?:form|forms)-mode=["']live["']/i.test(html)) signals.push('live form mode');
|
||||||
if (/api\.azinstitute4autism\.com/i.test(html)) signals.push('production API origin');
|
const counterReviewed = allowApprovedCounters
|
||||||
|
? html.replace(/<div\b[^>]*\bdata-counter\b[^>]*>/gi, (tag) =>
|
||||||
|
tag.replace('data-api-base="https://api.azinstitute4autism.com"', ''))
|
||||||
|
: html;
|
||||||
|
if (/api\.azinstitute4autism\.com/i.test(counterReviewed)) signals.push('production API origin');
|
||||||
if (/AIza[0-9A-Za-z_-]{20,}|-----BEGIN (?:RSA|OPENSSH|EC) PRIVATE\sKEY-----|(?:api[_-]?key|secret|password)\s*[:=]/i.test(html)) signals.push('credential-like content');
|
if (/AIza[0-9A-Za-z_-]{20,}|-----BEGIN (?:RSA|OPENSSH|EC) PRIVATE\sKEY-----|(?:api[_-]?key|secret|password)\s*[:=]/i.test(html)) signals.push('credential-like content');
|
||||||
return signals;
|
return signals;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyArtifact(root, mode = 'staging', forms = 'live', portal = { mode: 'none' }) {
|
export async function verifyArtifact(root, mode = 'staging', forms = 'live', portal = { mode: 'none' }, counters = 'off') {
|
||||||
if (!['staging', 'indexing'].includes(mode)) throw new Error('Artifact mode must be staging or indexing; form availability is selected separately with --forms=live|off|mock.');
|
if (!['staging', 'indexing'].includes(mode)) throw new Error('Artifact mode must be staging or indexing; form availability is selected separately with --forms=live|off|mock.');
|
||||||
if (!['live', 'off', 'mock'].includes(forms)) throw new Error('Artifact forms must be live, off, or mock.');
|
if (!['live', 'off', 'mock'].includes(forms)) throw new Error('Artifact forms must be live, off, or mock.');
|
||||||
|
if (!['live', 'off', 'mock'].includes(counters)) throw new Error('Artifact counters must be live, off, or mock.');
|
||||||
if (!portal || !['none', 'synthetic', 'production'].includes(portal.mode)) throw new Error('Portal artifact mode must be none, synthetic, or production.');
|
if (!portal || !['none', 'synthetic', 'production'].includes(portal.mode)) throw new Error('Portal artifact mode must be none, synthetic, or production.');
|
||||||
if (portal.mode === 'production' && (!/^[0-9a-f]{64}$/.test(portal.sha256 ?? '') || mode !== 'indexing')) throw new Error('Production portal verification requires indexing mode and its expected payload SHA-256.');
|
if (portal.mode === 'production' && (!/^[0-9a-f]{64}$/.test(portal.sha256 ?? '') || mode !== 'indexing')) throw new Error('Production portal verification requires indexing mode and its expected payload SHA-256.');
|
||||||
const formAEnabled = forms === 'live';
|
const formAEnabled = forms === 'live';
|
||||||
@@ -115,6 +120,7 @@ export async function verifyArtifact(root, mode = 'staging', forms = 'live', por
|
|||||||
let secretSignals = [];
|
let secretSignals = [];
|
||||||
let formAIframes = 0;
|
let formAIframes = 0;
|
||||||
let deferredFormAIframes = 0;
|
let deferredFormAIframes = 0;
|
||||||
|
let counterInstances = 0;
|
||||||
|
|
||||||
for (const file of htmlFiles) {
|
for (const file of htmlFiles) {
|
||||||
const html = await readText(file);
|
const html = await readText(file);
|
||||||
@@ -127,7 +133,20 @@ export async function verifyArtifact(root, mode = 'staging', forms = 'live', por
|
|||||||
const trailingSlashUrls = rootRelativeUrls(html).filter(hasTerminalSlash);
|
const trailingSlashUrls = rootRelativeUrls(html).filter(hasTerminalSlash);
|
||||||
const expectedFormAUrl = formAEnabled ? staticFormAUrls.get(route) : undefined;
|
const expectedFormAUrl = formAEnabled ? staticFormAUrls.get(route) : undefined;
|
||||||
const expectedDeferredFormAUrl = formAEnabled ? deferredFormAUrls.get(route) : undefined;
|
const expectedDeferredFormAUrl = formAEnabled ? deferredFormAUrls.get(route) : undefined;
|
||||||
const signals = unsafeSignals(html, formAEnabled);
|
const signals = unsafeSignals(html, formAEnabled, counters === 'live');
|
||||||
|
const countersOnPage = [...html.matchAll(/<div\b[^>]*\bdata-counter\b[^>]*>/gi)].map((match) => match[0]);
|
||||||
|
for (const counter of countersOnPage) {
|
||||||
|
if (counters === 'live') {
|
||||||
|
if (!counter.includes('data-counter-mode="live"')
|
||||||
|
|| !counter.includes('data-api-base="https://api.azinstitute4autism.com"')) {
|
||||||
|
errors.push(`${route}: live counter lacks the approved mode or API origin`);
|
||||||
|
}
|
||||||
|
} else if (counter.includes('data-counter-mode="live"')
|
||||||
|
|| counter.includes('data-api-base="https://api.azinstitute4autism.com"')) {
|
||||||
|
errors.push(`${route}: live counter appears in a ${counters} artifact`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
counterInstances += countersOnPage.length;
|
||||||
const jotformFrames = iframeSources(html).filter((src) => {
|
const jotformFrames = iframeSources(html).filter((src) => {
|
||||||
try {
|
try {
|
||||||
return new URL(src).origin === 'https://form.jotform.com';
|
return new URL(src).origin === 'https://form.jotform.com';
|
||||||
@@ -212,6 +231,7 @@ export async function verifyArtifact(root, mode = 'staging', forms = 'live', por
|
|||||||
if (reviewIndex) errors.push('indexing-enabled artifact contains staging-only /lp review index');
|
if (reviewIndex) errors.push('indexing-enabled artifact contains staging-only /lp review index');
|
||||||
}
|
}
|
||||||
if (activeForms) errors.push(`found ${activeForms} non-search native form action(s)`);
|
if (activeForms) errors.push(`found ${activeForms} non-search native form action(s)`);
|
||||||
|
if (counters === 'live' && counterInstances === 0) errors.push('live artifact contains no article counters');
|
||||||
if (secretSignals.length) errors.push(`unsafe artifact signals: ${JSON.stringify(secretSignals)}`);
|
if (secretSignals.length) errors.push(`unsafe artifact signals: ${JSON.stringify(secretSignals)}`);
|
||||||
|
|
||||||
const files = (await fg('**/*', { cwd: root, absolute: true, onlyFiles: true })).sort();
|
const files = (await fg('**/*', { cwd: root, absolute: true, onlyFiles: true })).sort();
|
||||||
@@ -241,6 +261,7 @@ export async function verifyArtifact(root, mode = 'staging', forms = 'live', por
|
|||||||
return {
|
return {
|
||||||
mode,
|
mode,
|
||||||
forms,
|
forms,
|
||||||
|
counters,
|
||||||
portal: portal.mode,
|
portal: portal.mode,
|
||||||
htmlRoutes: htmlFiles.length,
|
htmlRoutes: htmlFiles.length,
|
||||||
sitemapUrls: sitemapRoutes.length,
|
sitemapUrls: sitemapRoutes.length,
|
||||||
@@ -264,7 +285,8 @@ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(im
|
|||||||
const forms = process.argv.find((arg) => arg.startsWith('--forms='))?.slice('--forms='.length) ?? 'live';
|
const forms = process.argv.find((arg) => arg.startsWith('--forms='))?.slice('--forms='.length) ?? 'live';
|
||||||
const portalMode = process.argv.find((arg) => arg.startsWith('--portal='))?.slice('--portal='.length) ?? 'none';
|
const portalMode = process.argv.find((arg) => arg.startsWith('--portal='))?.slice('--portal='.length) ?? 'none';
|
||||||
const portalSha256 = process.argv.find((arg) => arg.startsWith('--portal-sha256='))?.slice('--portal-sha256='.length);
|
const portalSha256 = process.argv.find((arg) => arg.startsWith('--portal-sha256='))?.slice('--portal-sha256='.length);
|
||||||
const result = await verifyArtifact(path.resolve(root), mode, forms, { mode: portalMode, sha256: portalSha256 });
|
const counters = process.argv.find((arg) => arg.startsWith('--counters='))?.slice('--counters='.length) ?? 'off';
|
||||||
|
const result = await verifyArtifact(path.resolve(root), mode, forms, { mode: portalMode, sha256: portalSha256 }, counters);
|
||||||
console.log(JSON.stringify(result, null, 2));
|
console.log(JSON.stringify(result, null, 2));
|
||||||
if (!result.passed) process.exitCode = 1;
|
if (!result.passed) process.exitCode = 1;
|
||||||
}
|
}
|
||||||
@@ -110,6 +110,19 @@ test('artifact rejects production API origin even when Form A is approved', asyn
|
|||||||
assert.ok(result.errors.some((error) => error.includes('unsafe artifact signals')));
|
assert.ok(result.errors.some((error) => error.includes('unsafe artifact signals')));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('live counters allow only the reviewed API origin and explicit live mode', async () => {
|
||||||
|
const root = await fixture('indexing');
|
||||||
|
await fs.appendFile(path.join(root, 'index.html'), '<div data-counter data-counter-mode="live" data-api-base="https://api.azinstitute4autism.com"></div>');
|
||||||
|
assert.equal((await verifyArtifact(root, 'indexing', 'live', { mode: 'none' }, 'live')).passed, true);
|
||||||
|
assert.equal((await verifyArtifact(root, 'indexing', 'live', { mode: 'none' }, 'off')).passed, false);
|
||||||
|
|
||||||
|
const wrong = await fixture('indexing');
|
||||||
|
await fs.appendFile(path.join(wrong, 'index.html'), '<div data-counter data-counter-mode="live" data-api-base="https://other.invalid"></div>');
|
||||||
|
const result = await verifyArtifact(wrong, 'indexing', 'live', { mode: 'none' }, 'live');
|
||||||
|
assert.equal(result.passed, false);
|
||||||
|
assert.ok(result.errors.some((error) => error.includes('approved mode or API origin')));
|
||||||
|
});
|
||||||
|
|
||||||
test('artifact rejects Form A identity outside the exact approved HTML routes', async () => {
|
test('artifact rejects Form A identity outside the exact approved HTML routes', async () => {
|
||||||
const root = await fixture('staging');
|
const root = await fixture('staging');
|
||||||
await fs.writeFile(path.join(root, 'assets.js'), 'https://form.jotform.com/262946078314059?formContext=contact');
|
await fs.writeFile(path.join(root, 'assets.js'), 'https://form.jotform.com/262946078314059?formContext=contact');
|
||||||
@@ -182,4 +195,5 @@ test('artifact rejects extra routes, wrong origins and prematurely loaded drawer
|
|||||||
test('obsolete artifact modes are rejected with separate publication/form guidance', async () => {
|
test('obsolete artifact modes are rejected with separate publication/form guidance', async () => {
|
||||||
await assert.rejects(verifyArtifact('/unused','isolated-test'), /--forms=live\|off\|mock/);
|
await assert.rejects(verifyArtifact('/unused','isolated-test'), /--forms=live\|off\|mock/);
|
||||||
await assert.rejects(verifyArtifact('/unused','staging','invalid'), /live, off, or mock/);
|
await assert.rejects(verifyArtifact('/unused','staging','invalid'), /live, off, or mock/);
|
||||||
|
await assert.rejects(verifyArtifact('/unused','staging','live',{ mode: 'none' },'invalid'), /counters must be live/);
|
||||||
});
|
});
|
||||||
@@ -20,7 +20,7 @@ test('counter configuration defaults off and requires explicit approval', () =>
|
|||||||
assert.equal(resolveCounterConfig('live', 'https://api.example.test', 'false').mode, 'off');
|
assert.equal(resolveCounterConfig('live', 'https://api.example.test', 'false').mode, 'off');
|
||||||
assert.equal(resolveCounterConfig('live', 'https://api.example.test', 'true').mode, 'live');
|
assert.equal(resolveCounterConfig('live', 'https://api.example.test', 'true').mode, 'live');
|
||||||
assert.equal(resolveCounterConfig('live', 'http://api.example.test', 'true').mode, 'off');
|
assert.equal(resolveCounterConfig('live', 'http://api.example.test', 'true').mode, 'off');
|
||||||
assert.equal(counterModeForRuntime(resolveCounterConfig('live', 'https://api.example.test', 'true')), 'off');
|
assert.equal(counterModeForRuntime(resolveCounterConfig('live', 'https://api.example.test', 'true')), 'live');
|
||||||
assert.equal(resolveCounterConfig('mock', undefined, undefined).mode, 'mock');
|
assert.equal(resolveCounterConfig('mock', undefined, undefined).mode, 'mock');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,10 +73,15 @@ test('inactive source has no native form or live vendor destination', () => {
|
|||||||
assert.match(sources, /data-form-mode/);
|
assert.match(sources, /data-form-mode/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('counter source has no implicit production fallback or credentialed fetch', () => {
|
test('counter source requires explicit live configuration and omits credentials', () => {
|
||||||
const source = fs.readFileSync('src/components/LikeViewCounter.astro', 'utf8');
|
const component = fs.readFileSync('src/components/LikeViewCounter.astro', 'utf8');
|
||||||
assert.doesNotMatch(source, /api\.azinstitute4autism\.com/);
|
const runtime = fs.readFileSync('src/scripts/likes-views.ts', 'utf8');
|
||||||
assert.doesNotMatch(source, /credentials:\s*['"]include/);
|
const deploy = fs.readFileSync('tools/deploy.mjs', 'utf8');
|
||||||
assert.doesNotMatch(source, /method:\s*['"](?:POST|DELETE)/);
|
assert.doesNotMatch(component, /api\.azinstitute4autism\.com/);
|
||||||
assert.match(source, /data-counter-mode/);
|
assert.doesNotMatch(runtime, /credentials:\s*['"]include/);
|
||||||
|
assert.match(runtime, /credentials:\s*['"]omit/);
|
||||||
|
assert.match(component, /data-counter-mode/);
|
||||||
|
assert.match(deploy, /PUBLIC_COUNTERS_MODE:\s*'live'/);
|
||||||
|
assert.match(deploy, /PUBLIC_COUNTERS_LIVE_APPROVED:\s*'true'/);
|
||||||
|
assert.match(deploy, /PUBLIC_AIA_API_BASE:\s*'https:\/\/api\.azinstitute4autism\.com'/);
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user