Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: "CI"

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

jobs:
ci:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
- run: npm install
- run: npm run lint
- run: npm test
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"type": "module",
"scripts": {
"test": "jest",
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
"lint": "eslint src/"
},
"devDependencies": {
Expand Down
98 changes: 51 additions & 47 deletions src/callsign.js
Original file line number Diff line number Diff line change
Expand Up @@ -210,10 +210,10 @@ const PREFIX_TABLE = new Map([
]);

/** @constant */
const SEARCH_REGEX = /([A-Z,\d]{1,3}\d[A-Z]{1,3}(?:\/\d)?)\s/;
const SEARCH_REGEX = /([A-Z\d]{1,3}\d[A-Z]{1,3}(?:\/\d)?)\b/;

/** @constant */
const PARTS_REGEX = /([A-Z,\d]{1,3})(\d)([A-Z]{1,3})(?:\/(\d))?/;
const PARTS_REGEX = /([A-Z\d]{1,3})(\d)([A-Z]{1,3})(?:\/(\d))?/;

/** @constant */
const DEFAULT_CSS_PATH = 'callsign.css';
Expand All @@ -233,6 +233,15 @@ function getScriptElement() {
return scriptElement;
}

/** @constant */
const DEFAULT_CONFIG = {
flag: true,
monospace: true,
phonetic: true,
search: false,
cssPath: DEFAULT_CSS_PATH
};

/**
* Gets configuration from script element dataset
* @returns {Object}
Expand All @@ -241,21 +250,15 @@ function getConfig() {
if (!config) {
const script = getScriptElement();
if (!script) {
console.warn('callsign.js: Script element with id="callsign-js" not found');
return {
flag: 'true',
monospace: 'true',
phonetic: 'true',
search: 'false',
cssPath: DEFAULT_CSS_PATH
};
return { ...DEFAULT_CONFIG };
}
const ds = script.dataset;
config = {
flag: script.dataset.flag || 'true',
monospace: script.dataset.monospace || 'true',
phonetic: script.dataset.phonetic || 'true',
search: script.dataset.search || 'false',
cssPath: script.dataset.cssPath || DEFAULT_CSS_PATH
flag: ds.flag !== 'false',
monospace: ds.monospace !== 'false',
phonetic: ds.phonetic !== 'false',
search: ds.search === 'true',
cssPath: ds.cssPath || DEFAULT_CSS_PATH
};
}
return config;
Expand All @@ -270,12 +273,11 @@ class Callsign extends HTMLElement {
super();

const configuration = getConfig();
const callsignText = this.innerHTML.trim();
const callsignText = (this.textContent || '').trim();

// Validate call sign format
const match = callsignText.match(PARTS_REGEX);
if (!match) {
console.warn(`callsign.js: Invalid call sign format: ${callsignText}`);
return;
}

Expand All @@ -285,7 +287,7 @@ class Callsign extends HTMLElement {

const wrapper = document.createElement('span');
wrapper.classList.add('cs-wrapper');
if (configuration.monospace !== 'false') {
if (configuration.monospace) {
wrapper.classList.add('monospace');
}

Expand All @@ -296,14 +298,14 @@ class Callsign extends HTMLElement {
]);

// Add phonetic information
if (configuration.phonetic !== 'false') {
if (configuration.phonetic) {
const phonetic = Callsign.getPhonetics(match[0]);
wrapper.setAttribute('aria-label', phonetic);
wrapper.setAttribute('title', phonetic);
}

// Add country flag
if (configuration.flag !== 'false') {
if (configuration.flag) {
const flagElement = this.createFlagElement(parts.get('prefix'));
if (flagElement) {
wrapper.appendChild(flagElement);
Expand All @@ -315,7 +317,7 @@ class Callsign extends HTMLElement {
const partElement = document.createElement('span');
partElement.textContent = value;
partElement.className = `cs-${key}`;
if (configuration.phonetic !== 'false') {
if (configuration.phonetic) {
partElement.setAttribute('aria-hidden', 'true');
}
wrapper.appendChild(partElement);
Expand All @@ -336,14 +338,13 @@ class Callsign extends HTMLElement {
* @returns {HTMLSpanElement|null}
*/
createFlagElement(prefix) {
for (const [iso, prefixes] of PREFIX_TABLE) {
if (prefixes.includes(prefix)) {
const flagElement = document.createElement('span');
flagElement.className = 'cs-flag';
flagElement.title = iso;
flagElement.textContent = Callsign.getFlag(iso);
return flagElement;
}
const iso = Callsign._reversePrefixMap.get(prefix);
if (iso) {
const flagElement = document.createElement('span');
flagElement.className = 'cs-flag';
flagElement.title = iso;
flagElement.textContent = Callsign.getFlag(iso);
return flagElement;
}
return null;
}
Expand All @@ -362,14 +363,10 @@ class Callsign extends HTMLElement {
* @returns {string}
*/
static getPhonetics(letters) {
let ret = '';
for (let i = 0; i < letters.length; i++) {
const phonetic = PHONETIC_TABLE.get(letters.charAt(i));
if (phonetic) {
ret += `${phonetic} `;
}
}
return ret.slice(0, -1);
return Array.from(letters)
.map(c => PHONETIC_TABLE.get(c))
.filter(Boolean)
.join(' ');
}

/**
Expand All @@ -378,12 +375,7 @@ class Callsign extends HTMLElement {
* @returns {boolean}
*/
static isValidPrefix(prefix) {
for (const prefixes of PREFIX_TABLE.values()) {
if (prefixes.includes(prefix)) {
return true;
}
}
return false;
return Callsign._reversePrefixMap.has(prefix);
}

/**
Expand Down Expand Up @@ -427,9 +419,9 @@ class Callsign extends HTMLElement {
const matches = [];
let match;
let lastIndex = 0;
const regex = new RegExp(SEARCH_REGEX, 'g');
const regex = new RegExp(SEARCH_REGEX.source, 'g');

while ((match = regex.exec(`${text} `)) !== null) {
while ((match = regex.exec(text)) !== null) {
const callsign = match[1];
// Parse the call sign to extract the prefix
const parts = callsign.match(PARTS_REGEX);
Expand Down Expand Up @@ -474,13 +466,25 @@ class Callsign extends HTMLElement {
}
}

// Build reverse prefix→ISO map for O(1) lookups
Callsign._reversePrefixMap = new Map();
for (const [iso, prefixes] of PREFIX_TABLE) {
for (const prefix of prefixes) {
Callsign._reversePrefixMap.set(prefix, iso);
}
}

// Initialize when DOM is ready
if (getConfig().search !== 'false') {
if (getConfig().search) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => Callsign.searchCallsigns());
} else {
Callsign.searchCallsigns();
}
}

customElements.define('call-sign', Callsign);
if (!customElements.get('call-sign')) {
customElements.define('call-sign', Callsign);
}

if (typeof window !== 'undefined') window.Callsign = Callsign;
51 changes: 51 additions & 0 deletions tests/callsign.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* Integration tests for src/callsign.js loaded via jsdom.
* Verifies phonetics, prefix validation, and DOM search behaviour
* using the actual module rather than inline copies.
*/

import '../src/callsign.js';

describe('Callsign module (loaded via jsdom)', () => {
afterEach(() => {
document.body.innerHTML = '';
});
test('getPhonetics returns expected phonetic expansion', () => {
expect(window.Callsign.getPhonetics('W1AW')).toBe('Whiskey One Alfa Whiskey');
expect(window.Callsign.getPhonetics('SM8AYA')).toBe('Sierra Mike Eight Alfa Yankee Alfa');
});

test('getPhonetics filters out characters not in the phonetic table', () => {
// Slash is not in the phonetic table; it should be silently dropped
const result = window.Callsign.getPhonetics('W1ABC/3');
expect(result).not.toContain('undefined');
expect(result).toBe('Whiskey One Alfa Bravo Charlie Tree');
});

test('isValidPrefix returns true for known prefixes', () => {
expect(window.Callsign.isValidPrefix('W')).toBe(true); // US
expect(window.Callsign.isValidPrefix('DL')).toBe(true); // Germany
expect(window.Callsign.isValidPrefix('SM')).toBe(true); // Sweden
});

test('isValidPrefix returns false for unknown prefixes', () => {
expect(window.Callsign.isValidPrefix('ZZZ')).toBe(false);
expect(window.Callsign.isValidPrefix('')).toBe(false);
});

test('searchCallsigns replaces a text node containing a call sign with a call-sign element', () => {
document.body.innerHTML = '<p>W1AW is the ARRL station.</p>';
window.Callsign.searchCallsigns();
const el = document.querySelector('call-sign');
expect(el).toBeTruthy();
expect(el.textContent).toBe('W1AW');
});

test('searchCallsigns leaves surrounding text intact', () => {
document.body.innerHTML = '<p>Contact W1AW for more info.</p>';
window.Callsign.searchCallsigns();
const p = document.querySelector('p');
expect(p.textContent).toBe('Contact W1AW for more info.');
expect(document.querySelector('call-sign').textContent).toBe('W1AW');
});
});
Loading