Hi,
I made some new updates to cover some of the conditions. Though it might need some additional adjustments.
Step-by-step: Shamsi dates on Discourse frontend for Persian Language (if your language is in Arabic, make your adjustments in the code)
1) Create a Theme Component
-
Go to Admin → Customize → Themes
-
Click Components
-
Click Add → Create new
-
Name it: Shamsi Date Converter (Or whatever you want)
2) Add the script (Head section)
Inside the component:
-
Open Common → Head
-
Paste everything below
-
Save
Code:
<script>
(function () {
if (!document.documentElement.lang.startsWith("fa")) return;
// Formatters
const fullFormatter = new Intl.DateTimeFormat("fa-IR-u-ca-persian", { dateStyle: "medium" });
const monthYearFormatter = new Intl.DateTimeFormat("fa-IR-u-ca-persian", { year: "numeric", month: "long" });
const dayMonthFormatter = new Intl.DateTimeFormat("fa-IR-u-ca-persian", { day: "numeric", month: "long" });
const gregorianMonthsFa = new Map([
["ژانویه", 0], ["فوریه", 1], ["مارس", 2], ["آوریل", 3], ["مه", 4], ["ژوئن", 5],
["ژوئیه", 6], ["ژوئیهٔ", 6], ["اوت", 7], ["اگوست", 7], ["سپتامبر", 8],
["اکتبر", 9], ["نوامبر", 10], ["دسامبر", 11],
]);
function toLatinDigits(str) {
return (str || "")
.replace(/[۰-۹]/g, d => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)))
.replace(/[٠-٩]/g, d => String("٠١٢٣٤٥٦٧٨٩".indexOf(d)));
}
function toDate(value) {
if (!value) return null;
if (/^\d{10,13}$/.test(value)) {
const n = Number(value);
return new Date(value.length === 10 ? n * 1000 : n);
}
const d = new Date(value);
return isNaN(d.getTime()) ? null : d;
}
function processTimeElement(el) {
// Allow re-processing if Discourse overwrote text
// We mark last applied value so we can detect overwrite.
const date =
toDate(el.getAttribute("datetime")) ||
toDate(el.getAttribute("data-time")) ||
toDate(el.dataset && el.dataset.time) ||
toDate(el.getAttribute("title"));
if (!date) return;
const formatted = fullFormatter.format(date);
if (el.textContent !== formatted) {
el.textContent = formatted;
}
}
function findTimelineYear(rootEl) {
const container = rootEl.closest(".timeline-scrollarea-wrapper") || document;
const candidates = container.querySelectorAll(".timeline-date-wrapper, .start-date span, .timeline-ago");
for (const el of candidates) {
const text = (el.textContent || "").trim().replace(/\s+/g, " ");
const m = text.match(/^(\S+)\s+([۰-۹٠-٩0-9]{4})$/);
if (!m) continue;
const year = Number(toLatinDigits(m[2]));
if (Number.isFinite(year) && year >= 1970 && year <= 2100) return year;
}
return new Date().getFullYear();
}
function parseGregorianMonthYearFa(text) {
const cleaned = (text || "").trim().replace(/\s+/g, " ");
const parts = cleaned.split(" ");
if (parts.length !== 2) return null;
const monthIndex = gregorianMonthsFa.get(parts[0]);
const year = Number(toLatinDigits(parts[1]));
if (monthIndex === undefined || !Number.isFinite(year) || year < 1970 || year > 2100) return null;
return new Date(Date.UTC(year, monthIndex, 1));
}
function parseGregorianDayMonthFa(text, assumedYear) {
const cleaned = (text || "").trim().replace(/\s+/g, " ");
const parts = cleaned.split(" ");
if (parts.length !== 2) return null;
const day = Number(toLatinDigits(parts[0]));
const monthIndex = gregorianMonthsFa.get(parts[1]);
if (monthIndex === undefined || !Number.isFinite(day) || day < 1 || day > 31) return null;
const d = new Date(Date.UTC(assumedYear, monthIndex, day));
return isNaN(d.getTime()) ? null : d;
}
function processTimelineLabel(el) {
const text = (el.textContent || "").trim();
if (!text) return;
const d1 = parseGregorianMonthYearFa(text);
if (d1) {
const formatted = monthYearFormatter.format(d1);
if (el.textContent !== formatted) el.textContent = formatted;
return;
}
const year = findTimelineYear(el);
const d2 = parseGregorianDayMonthFa(text, year);
if (d2) {
const formatted = dayMonthFormatter.format(d2);
if (el.textContent !== formatted) el.textContent = formatted;
}
}
function run(root = document) {
// 1) Real time-based elements
root.querySelectorAll("time, .relative-date").forEach(processTimeElement);
// 2) Timeline plain labels
root.querySelectorAll(
".timeline-scrollarea-wrapper .timeline-ago, " +
".timeline-scrollarea-wrapper .start-date span, " +
".timeline-scrollarea-wrapper .timeline-date-wrapper span"
).forEach(processTimelineLabel);
}
// Initial
run();
// IMPORTANT: Observe text changes too, not just added nodes
const obs = new MutationObserver((muts) => {
for (const m of muts) {
// If nodes are added, process them
if (m.addedNodes && m.addedNodes.length) {
m.addedNodes.forEach(n => { if (n.nodeType === 1) run(n); });
}
// If text changes, re-run on the parent element
if (m.type === "characterData" && m.target && m.target.parentElement) {
run(m.target.parentElement);
}
}
});
obs.observe(document.documentElement, {
subtree: true,
childList: true,
characterData: true // <-- this is the key change
});
// Re-apply when tab becomes active again (Discourse often re-renders then)
function reapplySoon() {
// two passes handle immediate + next tick re-renders
run();
setTimeout(run, 250);
setTimeout(run, 1000);
}
document.addEventListener("visibilitychange", () => {
if (!document.hidden) reapplySoon();
});
window.addEventListener("focus", reapplySoon);
})();
</script>
Alternatively you can upload the what I exported and assign it to your themes.
discourse-shamsi-date.zip (2.4 KB)
How it works
Discourse already sends normal Gregorian dates to your browser; this script does not change the data, it only replaces how the date text is displayed on the page, converting it to Shamsi (Jalali) using the browser itself.
What the script does (high level)
-
Finds those <time> elements
-
Reads the real Gregorian date from datetime
-
Converts it to Shamsi
-
Replaces only the visible text
-
Repeats this whenever new posts load
I also updated the code for the issue with re-rendering. This happens when you step away from the tab, and discourse re-renders the page and it reverts back to Default Date. It appears it fixed the issue.
This will give you dates like this:
You can view it online on our instance: https://forums.7ho.st
Good luck