A useful project directory needs two separate answers: where will this link navigate, and what counts as the same resource? Treating those questions as one string-replacement operation creates subtle mistakes. A substring such as agprolabs.com can appear inside an unrelated hostname. Removing every query parameter can merge two different resources. Changing the destination while counting it can quietly alter what the reader opens.
I maintain AGProLabs, AliGurtuna.com and Babamın Şiir Defteri. Their resource pages include a TradingView review journal, a health-tool directory and a literary-review archive. These are distinct projects and readers. This example implements a small editorial URL policy for a resource menu across those three owned domains. It does not fetch pages or certify their content.
State the identity policy before writing the function
For this example:
- Require an absolute HTTPS address. Do not infer a base URL or silently add a protocol.
- Parse the URL, then compare the complete hostname against a set of three approved domains. Explicitly allow the
wwwaliases. - Reject credentials and non-default ports. The parser normalizes the default HTTPS port.
- Exclude selected administrative, account and private workflow paths.
- Keep the parsed navigation address, including its query and fragment.
- Build a separate reporting identity: strip
www, ignore the fragment and remove onlyutm_parameters. Sort the remaining query parameters. - Preserve path case and the trailing slash. Whether two such routes are equivalent needs site-specific evidence.
The www aliases and tracking-parameter equivalence are declared rules for this example, not universal canonicalization rules. A different website could route them differently. The MDN URL reference documents the parsed properties this function uses.
A small pure function
Save this as resource-link.mjs:
const domains = new Set([
'agprolabs.com', 'aligurtuna.com', 'babaminsiirdefteri.com'
]);
const blocked = /^(?:wp-admin|wp-login\.php|wp-json|cart|checkout|my-account|quick-purchase|crypto-screener(?:\.php)?)(?:\/|$)/i;
export function resourceLink(raw) {
if (typeof raw !== 'string' || !/^https:\/\//i.test(raw))
return { ok: false, reason: 'absolute_https_required' };
let url;
try { url = new URL(raw); }
catch { return { ok: false, reason: 'invalid_url' }; }
if (url.username || url.password || url.port)
return { ok: false, reason: 'credentials_or_port' };
const domain = url.hostname.replace(/^www\./, '');
if (!domains.has(domain)) return { ok: false, reason: 'unlisted_host' };
let path;
try { path = decodeURIComponent(url.pathname); }
catch { return { ok: false, reason: 'invalid_encoding' }; }
if (blocked.test(path.replace(/^\/+/, '')))
return { ok: false, reason: 'excluded_path' };
// Keep the navigable URL intact. Identity is a separate editorial policy.
const identity = new URL(url.href);
identity.hostname = domain;
identity.hash = '';
for (const name of [...identity.searchParams.keys()])
if (/^utm_/i.test(name)) identity.searchParams.delete(name);
identity.searchParams.sort();
return { ok: true, href: url.href, identity: identity.href };
}
The result has an explicit rejection reason. A rejected row should stay in the editor with its reason visible; it should not be converted into a plausible-looking replacement link. For an accepted row, href is the parsed navigation address and identity is a reporting key. This keeps counting policy out of the reader's navigation path.
Use textContent when placing a user-supplied label in a DOM element. This function does not sanitize HTML labels. Likewise, do not treat an accepted origin as proof that a page returns HTTP 200, that its redirect destination remains approved, or that it is safe to fetch from a server. Those are separate validations. This is not an SSRF defense or a complete security boundary.
Test the cases that a substring check misses
Save the second file as resource-link.test.mjs. It uses Node's strict assertion module and imports the first file.
import assert from 'node:assert/strict';
import { resourceLink } from './resource-link.mjs';
const rejectCases = [
[undefined, 'absolute_https_required'],
['/insights/', 'absolute_https_required'],
['http://agprolabs.com/', 'absolute_https_required'],
['javascript:alert(1)', 'absolute_https_required'],
['https://', 'invalid_url'],
['https://agprolabs.com.attacker.example/', 'unlisted_host'],
['https://attacker.example/?site=agprolabs.com', 'unlisted_host'],
['https://agprolabs.com@attacker.example/', 'credentials_or_port'],
['https://user:secret@agprolabs.com/', 'credentials_or_port'],
['https://agprolabs.com:8443/', 'credentials_or_port'],
['https://www.agprolabs.com/wp-admin/', 'excluded_path'],
['https://www.agprolabs.com/%77p-admin/', 'excluded_path'],
['https://www.agprolabs.com/insights/../checkout/', 'excluded_path'],
['https://www.agprolabs.com/crypto-screener.php', 'excluded_path'],
['https://www.aligurtuna.com/%ZZ', 'invalid_encoding'],
];
for (const [input, reason] of rejectCases)
assert.deepEqual(resourceLink(input), { ok: false, reason });
let count = rejectCases.length;
for (const host of ['agprolabs.com', 'aligurtuna.com', 'babaminsiirdefteri.com']) {
assert.equal(resourceLink(`https://${host}/`).ok, true); count++;
}
const original = 'https://www.agprolabs.com/insights/?utm_source=dev&b=2&a=1#notes';
assert.equal(resourceLink(original).href, original); count++;
assert.equal(resourceLink(original).identity, 'https://agprolabs.com/insights/?a=1&b=2'); count++;
assert.notEqual(resourceLink('https://agprolabs.com/?id=1').identity,
resourceLink('https://agprolabs.com/?id=2').identity); count++;
assert.notEqual(resourceLink('https://agprolabs.com/guide').identity,
resourceLink('https://agprolabs.com/guide/').identity); count++;
assert.equal(resourceLink('https://AGPROLABS.COM:443/').ok, true); count++;
assert.equal(resourceLink('https://www.agprolabs.com/insights/').identity,
resourceLink('https://agprolabs.com/insights/').identity); count++;
assert.equal(resourceLink('https://agprolabs.com/?UTM_SOURCE=dev').identity,
'https://agprolabs.com/'); count++;
console.log(JSON.stringify({ passed: count, failed: 0, scope: 'Pure URL-policy tests; no HTTP requests or security certification' }));
Run node resource-link.test.mjs in their shared folder. In the local check for this article, 25 assertions passed. They cover deceptive hostnames, credential-bearing addresses, a private screener route, malformed encoding, parsed dot segments, all three approved domains, retained meaningful query parameters and the deliberate trailing-slash distinction. This is evidence for these fixtures, not exhaustive proof about arbitrary URLs or live websites.
Keep the reader's purpose visible
A resource card needs more than a permitted address. Include a label that says what the destination provides and whether the article is maintained by the same author. For example, an AGProLabs card can lead to the Crypto Setup Planner product description or the candidate-to-chart review guide, depending on the question the card answers. Discovery methodology and a software purchase are different destinations.
In the health project, the psychological-awareness tools and health-category directory also serve different browsing tasks. A valid URL is not evidence of diagnostic accuracy. In the literature project, the poets and poems directory supports author-based navigation, while the linked review archive supports analytical reading. URL normalization should not invent literary attribution or merge different works.
Where the policy stops
Before publication, separately verify the actual destination, its intended public status and any redirect or canonical behavior. Keep the parsed navigation address and a record of the reason for rejection. Review the allowlist when a project adds a domain; do not make suffix matching progressively looser to get another row through.
The source-page × destination count can help audit a menu or publication log. It is not a count of independent referring domains, indexed backlinks or SEO authority. The same lesson applies across the three projects: an accepted input has a bounded meaning, and the interface should communicate that meaning clearly.
Disclosure: I maintain the projects linked in the examples. This article and code were prepared with AI assistance; the local fixture run is recorded separately. The health and trading references are product/resource context, not clinical or financial advice.
Top comments (0)