cz-typography
Version:
Universal Czech typography fixer for JavaScript, React, Next.js, and any SSR framework. Non-breaking spaces after one-letter prepositions, units, dates, ordinals and more.
450 lines (432 loc) • 12.8 kB
JavaScript
;
Object.defineProperty(exports, '__esModule', { value: true });
var React = require('react');
var jsxRuntime = require('react/jsx-runtime');
var server = require('react-dom/server');
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
var React__default = /*#__PURE__*/_interopDefault(React);
// src/TypoWrapper.js
// src/rules/dates.js
var FULL_DATE_REGEX = /(\d{1,2})\.[ \t]+(\d{1,2})\.[ \t]+(\d{2,4})\b/g;
var DAY_MONTH_REGEX = /(\d{1,2})\.[ \t]+(\d{1,2})\.(?=[^\p{L}\p{N}]|$)/gu;
function applyDates(text) {
return text.replace(FULL_DATE_REGEX, "$1.\xA0$2.\xA0$3").replace(DAY_MONTH_REGEX, "$1.\xA0$2.");
}
// src/rules/initials.js
var INITIAL_REGEX = /(\b\p{Lu})\.[ \t]+(?=\p{Lu})/gu;
function applyInitials(text) {
let previous;
let current = text;
do {
previous = current;
current = current.replace(INITIAL_REGEX, "$1.\xA0");
} while (current !== previous);
return current;
}
// src/rules/ordinals.js
var ORDINAL_REGEX = /(\d{1,2})\.[ \t]+(?=\p{Ll})/gu;
function applyOrdinals(text) {
return text.replace(ORDINAL_REGEX, "$1.\xA0");
}
// src/data/prepositions.js
var PREPOSITIONS = Object.freeze([
"a",
"i",
"k",
"o",
"s",
"u",
"v",
"z"
]);
// src/rules/prepositions.js
var PREPOSITION_REGEX = new RegExp(
`(?<=^|[\\s(\\["'\xA0\u2018\u201C\xBB\u2014\u2013-])(${PREPOSITIONS.join("|")})[ \\t]+(?=\\S)`,
"giu"
);
function applyPrepositions(text) {
return text.replace(PREPOSITION_REGEX, "$1\xA0");
}
// src/rules/roman.js
var ROMAN_NUMERAL = "(?:(?=[IVXLCDM])M{0,3}(?:CM|CD|D?C{0,3})(?:XC|XL|L?X{0,3})(?:IX|IV|V?I{0,3}))";
var ROMAN_REGEX = new RegExp(
`(\\b\\p{Lu}\\p{L}+)[ \\t]+(?=${ROMAN_NUMERAL}\\.(?=[^\\p{L}\\p{N}]|$))`,
"gu"
);
function applyRoman(text) {
return text.replace(ROMAN_REGEX, "$1\xA0");
}
// src/rules/thousands.js
var THOUSANDS_REGEX = /(\d)[ \t](\d{3})(?=\D|$)/g;
function applyThousands(text) {
let previous;
let current = text;
do {
previous = current;
current = current.replace(THOUSANDS_REGEX, "$1\xA0$2");
} while (current !== previous);
return current;
}
// src/data/units.js
var SAFE_UNITS = Object.freeze(
[
"km/h",
"m/s",
"kWh",
"mAh",
"mbar",
"kbps",
"Mbps",
"Gbit",
"kPa",
"bar",
"kg",
"mg",
"cm",
"mm",
"\xB5m",
"nm",
"km",
"ml",
"cl",
"dl",
"kJ",
"Nm",
"Wb",
"lm",
"lx",
"cd",
"mol",
"min",
"ms",
"ns",
"Hz",
"Pa",
"kW",
"MW",
"Ohm",
"\xB0C",
"\xB0F",
"\xB0",
"\u2030",
"%",
"px",
"pt",
"dpi",
"bpm",
"EUR",
"USD",
"CZK",
"K\u010D",
"mg/m\xB3",
"str",
"osob",
"ks",
"MJ",
"TB",
"GB",
"MB",
"kB",
"bit",
"GBit",
"mol",
"\u03A9",
"\u20AC",
"$",
"\xA3"
].sort((a, b) => b.length - a.length)
);
var STRICT_UNITS = Object.freeze(
["m", "s", "l", "t", "g", "V", "A", "W", "J", "K", "B"].sort(
(a, b) => b.length - a.length
)
);
// src/rules/units.js
function escapeRegex(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
var SAFE_PATTERN = SAFE_UNITS.map(escapeRegex).join("|");
var STRICT_PATTERN = STRICT_UNITS.map(escapeRegex).join("|");
var SAFE_UNIT_REGEX = new RegExp(
`(\\d)[ \\t]+(?=(?:${SAFE_PATTERN})(?![\\p{L}\\p{N}]))`,
"gu"
);
var STRICT_UNIT_REGEX = new RegExp(
`(\\d)[ \\t]+(?=(?:${STRICT_PATTERN})(?:[.,;:!?)\\]/\\u00A0]|$))`,
"gu"
);
function applyUnits(text) {
return text.replace(SAFE_UNIT_REGEX, "$1\xA0").replace(STRICT_UNIT_REGEX, "$1\xA0");
}
// src/fixCzech.js
var DEFAULT_OPTIONS = Object.freeze({
prepositions: true,
units: true,
initials: true,
dates: true,
ordinals: true,
roman: true,
thousands: true
});
function fixCzech(text, options) {
if (typeof text !== "string" || text.length === 0) {
return text;
}
const opts = options ? { ...DEFAULT_OPTIONS, ...options } : DEFAULT_OPTIONS;
let result = text;
if (opts.thousands) result = applyThousands(result);
if (opts.dates) result = applyDates(result);
if (opts.ordinals) result = applyOrdinals(result);
if (opts.units) result = applyUnits(result);
if (opts.initials) result = applyInitials(result);
if (opts.roman) result = applyRoman(result);
if (opts.prepositions) result = applyPrepositions(result);
return result;
}
// src/fixCzechDom.js
var SKIPPED_TAGS = /* @__PURE__ */ new Set(["SCRIPT", "STYLE", "PRE", "CODE", "TEXTAREA"]);
var ELEMENT_NODE = 1;
var TEXT_NODE = 3;
var processedNodes = /* @__PURE__ */ new WeakSet();
function shouldSkipParent(element) {
let parent = element;
while (parent && parent.nodeType === ELEMENT_NODE) {
if (SKIPPED_TAGS.has(parent.nodeName)) return true;
if (parent.isContentEditable) return true;
parent = parent.parentNode;
}
return false;
}
function processTextNode(node, options) {
if (!node.nodeValue) return;
if (processedNodes.has(node)) return;
if (shouldSkipParent(node.parentNode)) return;
const fixed = fixCzech(node.nodeValue, options);
if (fixed !== node.nodeValue) {
node.nodeValue = fixed;
}
processedNodes.add(node);
}
function walk(node, options) {
if (!node) return;
if (node.nodeType === TEXT_NODE) {
processTextNode(node, options);
return;
}
if (node.nodeType !== ELEMENT_NODE) return;
if (SKIPPED_TAGS.has(node.nodeName)) return;
if (node.isContentEditable) return;
let child = node.firstChild;
while (child) {
const next = child.nextSibling;
walk(child, options);
child = next;
}
}
function fixCzechDom(root, options) {
if (!root || typeof root !== "object" || typeof root.nodeType !== "number") return;
walk(root, options);
}
function observeCzechDom(root, options) {
if (!root) return () => {
};
fixCzechDom(root, options);
if (typeof MutationObserver === "undefined") return () => {
};
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === "characterData") {
processedNodes.delete(mutation.target);
if (mutation.target.nodeType === TEXT_NODE) {
processTextNode(mutation.target, options);
}
continue;
}
for (const added of mutation.addedNodes) {
walk(added, options);
}
}
});
observer.observe(root, {
childList: true,
subtree: true,
characterData: true
});
return () => observer.disconnect();
}
function processChildren(node, options) {
if (typeof node === "string") {
return fixCzech(node, options);
}
if (typeof node === "number" || typeof node === "boolean" || node == null) {
return node;
}
if (Array.isArray(node)) {
return node.map((child, index) => {
const processed = processChildren(child, options);
if (React__default.default.isValidElement(processed) && processed.key == null) {
return React__default.default.cloneElement(processed, { key: index });
}
return processed;
});
}
if (React__default.default.isValidElement(node)) {
const props = node.props;
if (props == null || props.children == null) return node;
return React__default.default.cloneElement(node, {
...props,
children: processChildren(props.children, options)
});
}
return node;
}
function normaliseOptions(props) {
if (props.options) return props.options;
const {
prepositions,
units,
initials,
dates,
ordinals,
roman,
thousands
} = props;
const result = {};
if (prepositions !== void 0) result.prepositions = prepositions;
if (units !== void 0) result.units = units;
if (initials !== void 0) result.initials = initials;
if (dates !== void 0) result.dates = dates;
if (ordinals !== void 0) result.ordinals = ordinals;
if (roman !== void 0) result.roman = roman;
if (thousands !== void 0) result.thousands = thousands;
return result;
}
function TypoWrapper(props) {
const { children, mode = "jsx", as: Tag = "span" } = props;
const options = React.useMemo(
() => normaliseOptions(props),
// Each tracked prop is a primitive boolean (or an object reference
// explicitly supplied by the caller), so listing them keeps the
// memo stable across renders that pass identical config.
// eslint-disable-next-line react-hooks/exhaustive-deps
[
props.options,
props.prepositions,
props.units,
props.initials,
props.dates,
props.ordinals,
props.roman,
props.thousands
]
);
const containerRef = React.useRef(null);
const processedJsx = React.useMemo(
() => mode === "jsx" ? processChildren(children, options) : null,
[mode, children, options]
);
React.useEffect(() => {
if (mode !== "dom") return void 0;
const target = containerRef.current;
if (!target) return void 0;
return observeCzechDom(target, options);
}, [mode, options]);
if (mode === "dom") {
return /* @__PURE__ */ jsxRuntime.jsx(Tag, { ref: containerRef, style: { display: "contents" }, children });
}
return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: processedJsx });
}
var TypoWrapper_default = TypoWrapper;
// src/fixCzechHtml.js
var RAW_TEXT_TAGS = /* @__PURE__ */ new Set(["script", "style", "pre", "code", "textarea"]);
var TOKEN_REGEX = /<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<![A-Za-z][^>]*>|<\?[\s\S]*?\?>|<\/?([A-Za-z][A-Za-z0-9-]*)\b[^>]*>|[^<]+/g;
var SENTINEL = "";
function isTextToken(match) {
return match.charCodeAt(0) !== 60;
}
function isTagToken(match) {
return match.charCodeAt(0) === 60 && !match.startsWith("<!--") && !match.startsWith("<![") && !match.startsWith("<!") && !match.startsWith("<?");
}
function fixWithLookahead(text, options, nextChar) {
if (nextChar && /\S/.test(nextChar)) {
const padded = text + nextChar + SENTINEL;
const fixed = fixCzech(padded, options);
if (fixed.endsWith(nextChar + SENTINEL)) {
return fixed.slice(0, -(nextChar.length + SENTINEL.length));
}
return fixed.endsWith(SENTINEL) ? fixed.slice(0, -SENTINEL.length) : fixed;
}
return fixCzech(text, options);
}
function transformOne(match, tagName, rawTextStack, options, nextChar) {
if (!isTagToken(match) && match.charCodeAt(0) === 60) {
return match;
}
if (isTagToken(match)) {
const isClosing = match.charCodeAt(1) === 47;
const tag = tagName ? tagName.toLowerCase() : "";
if (isClosing) {
const top = rawTextStack[rawTextStack.length - 1];
if (top && top === tag) rawTextStack.pop();
} else if (RAW_TEXT_TAGS.has(tag) && !/\/\s*>$/.test(match)) {
rawTextStack.push(tag);
}
return match;
}
if (rawTextStack.length > 0) return match;
return fixWithLookahead(match, options, nextChar);
}
function tokenize(html) {
const tokens = [];
TOKEN_REGEX.lastIndex = 0;
let m;
while (m = TOKEN_REGEX.exec(html)) {
tokens.push({ match: m[0], tagName: m[1], end: m.index + m[0].length });
}
return tokens;
}
function lookaheadOf(tokens, index, fallback) {
const next = tokens[index + 1];
if (!next) return "";
if (isTextToken(next.match)) return next.match.charAt(0);
return "<";
}
function transformTokens(tokens, rawTextStack, options, trailingChar) {
let output = "";
for (let i = 0; i < tokens.length; i++) {
const { match, tagName } = tokens[i];
const nextChar = lookaheadOf(tokens, i);
output += transformOne(match, tagName, rawTextStack, options, nextChar);
}
return output;
}
function fixCzechHtml(html, options) {
if (typeof html !== "string" || html.length === 0) return html;
return transformTokens(tokenize(html), [], options);
}
function Typo({ children, options, as: Tag = "span" }) {
const html = server.renderToStaticMarkup(/* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children }));
const fixed = fixCzechHtml(html, options);
return /* @__PURE__ */ jsxRuntime.jsx(Tag, { dangerouslySetInnerHTML: { __html: fixed } });
}
function useFixCzech(text, options) {
const optionsKey = options ? JSON.stringify(options) : "";
return React.useMemo(
() => fixCzech(text, options),
// optionsKey is a serialised representation of `options`, so it
// captures the relevant dependencies without re-firing on a fresh
// object literal with the same values.
// eslint-disable-next-line react-hooks/exhaustive-deps
[text, optionsKey]
);
}
exports.Typo = Typo;
exports.TypoWrapper = TypoWrapper;
exports.default = TypoWrapper_default;
exports.fixCzech = fixCzech;
exports.fixCzechDom = fixCzechDom;
exports.fixCzechHtml = fixCzechHtml;
exports.observeCzechDom = observeCzechDom;
exports.useFixCzech = useFixCzech;
//# sourceMappingURL=react.cjs.map
//# sourceMappingURL=react.cjs.map