// @ts-check
/** @typedef {import('./_types.js').Provider} Provider */
// We Work Remotely provider - board-wide RSS feed
// (https://weworkremotely.com/remote-jobs.rss). The feed is public, no-auth,
// and XML, so it is parsed in-process with the same tiny tag extractor approach
// as providers/personio.mjs rather than adding an XML dependency.
//
// Wire in via a `job_boards:` entry with `provider: weworkremotely`.
const FEED_URL = 'https://weworkremotely.com/remote-jobs.rss';
const TRUSTED_HOST = 'weworkremotely.com';
/** @param {string} url */
function assertWwrUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
throw new Error(`weworkremotely: invalid URL: ${url}`);
}
if (parsed.protocol !== 'https:') throw new Error(`weworkremotely: URL must use HTTPS: ${url}`);
if (parsed.hostname !== TRUSTED_HOST) {
throw new Error(`weworkremotely: untrusted hostname "${parsed.hostname}" - must be ${TRUSTED_HOST}`);
}
return url;
}
// NaN-safe Date.parse - `|| undefined` would also coerce a valid epoch 0.
function toEpochMs(value) {
if (!value) return undefined;
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? undefined : parsed;
}
function fallbackCompany(entry) {
return typeof entry?.name === 'string' && entry.name.trim() ? entry.name.trim() : 'We Work Remotely';
}
/** @type {Provider} */
export default {
id: 'weworkremotely',
detect(entry) {
return entry?.provider === 'weworkremotely' ? { url: FEED_URL } : null;
},
async fetch(entry, ctx) {
const feedUrl = assertWwrUrl(FEED_URL);
// redirect:'error' prevents SSRF via server-side redirects; combined with
// assertWwrUrl above it keeps the request pinned to weworkremotely.com.
const text = await ctx.fetchText(feedUrl, { redirect: 'error' });
return parseWwrFeed(text, fallbackCompany(entry));
},
};
function fromCodePoint(cp) {
try {
return String.fromCodePoint(cp);
} catch {
return '';
}
}
// Decode the XML entities that appear in RSS text: numeric (& / ')
// and the named five. Numeric forms are decoded first; & is decoded LAST
// so a literal "<" yields "<" rather than over-decoding to "<".
function decodeXmlEntities(s) {
return s
.replace(/([0-9a-fA-F]+);/g, (_, h) => fromCodePoint(parseInt(h, 16)))
.replace(/(\d+);/g, (_, d) => fromCodePoint(parseInt(d, 10)))
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/&/g, '&');
}
// Resolve a tag's inner text: unwrap a CDATA section, else decode entities.
function extractText(inner) {
const cdata = inner.match(/^\s*\s*$/);
if (cdata) return cdata[1].trim();
return decodeXmlEntities(inner).trim();
}
// Extract the text of the first