From 70720bfb61bce368a4d8a03afe4c956da91a00aa Mon Sep 17 00:00:00 2001 From: 1mb-dev <1mb@1mb.dev> Date: Mon, 12 Oct 2026 01:22:50 +0530 Subject: [PATCH] feat(page): open job links in a new tab --- docs/design.md | 2 +- public/app.js | 10 +++++++--- test/app.test.ts | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 test/app.test.ts diff --git a/docs/design.md b/docs/design.md index 019b742..d11c58d 100644 --- a/docs/design.md +++ b/docs/design.md @@ -48,7 +48,7 @@ mobile age │ company │ sour Age leads, in mono with tabular figures: `12m`, `3h`, `2d`. Company and role come from the title's `Company | Role | …` convention (`parts` in `public/lib.js`); URLs are dropped from display because the link is built from a validated id, and HN job stories have no company part. The source is one word: `HN`, `HN jobs`, `board`. The filter matches company and role only. -Rows wrap; they never truncate. In HN titles the restriction ("US only", "visa possible") usually comes last, which is what an ellipsis would hide. Titles are capped at 160 characters upstream. Touch screens get rows of at least 44 px; mouse rows stay dense. +A row opens its posting in a new tab, so the list keeps its ticks, scroll and filter while you read; no icon or note says so. Rows wrap; they never truncate. In HN titles the restriction ("US only", "visa possible") usually comes last, which is what an ellipsis would hide. Titles are capped at 160 characters upstream. Touch screens get rows of at least 44 px; mouse rows stay dense. ## New and read diff --git a/public/app.js b/public/app.js index d6b0e3d..6b2a4cb 100644 --- a/public/app.js +++ b/public/app.js @@ -42,14 +42,18 @@ const onScreen = new IntersectionObserver( /** * @param {string} tag - * @param {{ class?: string, text?: string, href?: string }} [props] + * @param {{ class?: string, text?: string, href?: string, target?: string, rel?: string }} [props] * @param {(Node | null)[]} [children] */ function el(tag, props = {}, children = []) { const node = document.createElement(tag); if (props.class) node.className = props.class; if (props.text !== undefined) node.textContent = props.text; - if (props.href !== undefined && node instanceof HTMLAnchorElement) node.href = props.href; + if (node instanceof HTMLAnchorElement) { + if (props.href !== undefined) node.href = props.href; + if (props.target !== undefined) node.target = props.target; + if (props.rel !== undefined) node.rel = props.rel; + } for (const child of children) if (child) node.append(child); return node; } @@ -125,7 +129,7 @@ function render() { function row(item, now) { const fresh = isNew(item, seen); const { company, role } = parts(item); - const a = el('a', { class: ['row', fresh ? 'new' : '', isRead(item, seen) ? 'read' : ''].filter(Boolean).join(' '), href: item.url }, [ + const a = el('a', { class: ['row', fresh ? 'new' : '', isRead(item, seen) ? 'read' : ''].filter(Boolean).join(' '), href: item.url, target: '_blank', rel: 'noopener' }, [ el('span', { class: 'age' }, [el('span', { text: age(item.posted_at, now) }), fresh ? el('span', { class: 'mark', text: 'new' }) : null]), el('span', { class: 'co', text: company }), el('span', { class: 'role', text: role }), diff --git a/test/app.test.ts b/test/app.test.ts new file mode 100644 index 0000000..541c556 --- /dev/null +++ b/test/app.test.ts @@ -0,0 +1,14 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +// app.js drives the DOM at import, so it is pinned by source rather than run. +const source = readFileSync(new URL('../public/app.js', import.meta.url), 'utf8'); +const row = source.match(/^function row\([\s\S]*?^\}/m)?.[0] ?? ''; + +describe('row link', () => { + it('opens the posting in a new tab without handing it the page', () => { + expect(row).not.toBe(''); + expect(row).toContain("target: '_blank'"); + expect(row).toContain("rel: 'noopener'"); + }); +});