Represent Canonical URLs, Publication Dates, Revisions, and Past and Present Accurately
Use canonical addresses, dates, validity periods, and working code to explain which revision of a document applies when.
Addresses and time answer different questions
‘Where is the original?’, ‘When was it published?’, and ‘Does it apply now?’ are different questions. A canonical URL identifies the representative address for duplicate or very similar documents; publication and modification dates describe document history. The validity period describes when fees, timetables, and eligibility stated in the body apply.
Suppose the fictional library’s 2025 timetable is moved to a new CMS in 2026. Using migration time as first publication and renaming it ‘Current opening hours’ makes historical material look current. Pointing its canonical to the current timetable also signals that facts from different periods are one document. Preserve the original period, source, and self-referencing address, and link current guidance so readers can check historical rules.
Search systems group documents and estimate dates shown in results. Generative answers must select material appropriate to the question’s time. Consistent addresses, periods, and content give people and tools a basis for comparison. In Search Console, read the selected canonical and last crawl time; in AI answers, compare the cited page and the validity period of its statement.
Distinguish duplicates needing canonicalization from separate documents
Canonicalization selects a representative URL from multiple candidates. If only tracking UTM parameters or output format differ for the same article, a common canonical can be used. Internal links and sitemaps should also use it. Google describes redirects and rel=canonical as strong signals and sitemaps as weaker signals; the search system selects the final canonical. Canonical URL guidelines
A browser reading a canonical link stays at the original URL. Use a 301 or 308 redirect to move visitors. Protect private documents with authentication and authorization; provide noindex to exclude public documents from search.
When the base and UTM versions of a notice are crawled separately, search systems must compare them and select a canonical. Aligning canonical and internal links consolidates signals for identical content at the intended address and consistently suggests the URL for results. Historical and current notices answer for different periods and keep separate addresses. Check mistaken use of past facts as current facts through the document’s period explanation and actual AI citations too.
Separate creation, publication, revision, event, and effective times
createdAt is the internal time a draft was created. Store a verified first-publication date as publishedAt and use it for JSON-LD datePublished. contentModifiedAt is the substantive change time of public content and supplies dateModified. Changing it on every save, deployment, or crawl makes it impossible to tell which fact changed when.
An event date is when the described event happened. The date an operating policy was approved, the notice published, and the new timetable took effect may differ. Distinguish ‘Published,’ ‘Updated,’ and ‘Validity period’ labels in the body. Google’s date guidance calls for consistent publication and substantive modification dates in visible content and structured data, without confusing them with event dates. Page-date guidelines
A sitemap’s lastmod represents a significant change at that URL. Derive it from actual changes to body, important links, or structured data. Omit it without an accurate record. Simply adding today’s date to an old article does not explain a substantive update. Sitemap guidelines · Content-update guidelines
If a notice says ‘Published October 24; opening hours change from November 1’ and is read on October 25, the old hours still apply. Selecting the newest material by publication date alone can report future hours as today’s. Publication and revision dates indicate when a document appeared; validity periods indicate when its facts apply. Write ‘from November 1’ in the body too, and distinguish current from scheduled guidance.
Represent publication state independently from current or historical applicability
A historical record can also be public. Separate publicationStatus—draft, published, withdrawn—from the applicability of its facts. Deleting a record because it no longer applies prevents answers to questions about past policies or fees. Decide public availability after evaluating preservation value and access policy.
The document ID identifies the same article, revision an edited version, and slug the name used in its address. Preserve stable ID and revision history when title or slug changes for the same document. If current guidance and historical records are separate documents, give them different IDs and explain the relationship with ‘Current guidance’ and ‘Previous guidance’ links. supersedes can serve as an internal relationship indicating that a later document replaces an earlier one.
validFrom and validUntil store applicability periods in the example model. Put publication and modification dates in WebPage; display validity in the body, such as ‘January 1–December 31, 2025.’ Schema.org properties are defined per document type. WebPage properties
Turn date and address decisions into executable functions
Save the following as identity-time.mjs. It runs on Node.js 22 without external packages. dateOnly validates an actual YYYY-MM-DD calendar date and returns it unchanged. instant validates a second-precision timestamp with an explicit time zone. Do not invent midnight or a time zone for an event whose date alone is known.
ISO 8601 is an international standard for date and time representation. This example uses second-precision dates, times, and UTC offsets such as 2026-10-23T12:00:00+09:00. +09:00 means nine hours ahead of UTC; the same instant in UTC is 2026-10-23T03:00:00Z. Services requiring seasonal changes in regional time zones must manage those rules too.
canonicalUrl builds a URL from a stored path and page number that changes the content. Allowed paths here are restricted to lowercase Latin letters, digits, and hyphens. Extend the data contract if a real service supports Korean slugs or other parameters. Since the request Host and entire query are not copied, UTM values and arbitrary domains do not enter the canonical address.
// Node.js 22, No external packages. Timestamps use seconds; publication and modification may be date-only.
export function dateOnly(value) {
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value))
throw new Error('Invalid date');
const ms = Date.parse(value + 'T00:00:00Z');
if (!Number.isFinite(ms) || new Date(ms).toISOString().slice(0, 10) !== value)
throw new Error('Invalid date');
return value; // Do not shift an event date across time zones.
}
export function instant(value) {
const m = typeof value === 'string' && value.match(
/^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})(Z|[+-]\d{2}:\d{2})$/);
if (!m) throw new Error('Timezone required');
dateOnly(m[1]);
const offset = m[5] === 'Z' ? [0, 0] : m[5].slice(1).split(':').map(Number);
if (+m[2] > 23 || +m[3] > 59 || +m[4] > 59 || offset[0] > 23 || offset[1] > 59)
throw new Error('Invalid time');
const ms = Date.parse(value);
if (!Number.isFinite(ms)) throw new Error('Invalid time');
return ms;
}
export function canonicalUrl(path, page = 1, origin = 'https://example.com') {
// Use stored document paths and meaningful page numbers, not the request Host or query.
if (!/^\/[a-z0-9-]+(?:\/[a-z0-9-]+)*$/.test(path)) throw new Error('Invalid path');
if (!Number.isSafeInteger(page) || page < 1) throw new Error('Invalid page');
const url = new URL(path, origin);
if (page > 1) url.searchParams.set('page', String(page));
return url.href;
}
// Publication accepts Date or second-precision DateTime; preserve date-only precision.
export function contentDate(value) {
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return dateOnly(value);
instant(value);
return value;
}
// Comparison bounds only; do not invent stored or displayed publication times.
// Dates in this exercise use the library calendar in Asia/Seoul.
function bounds(value) {
contentDate(value);
if (value.length > 10) return [instant(value), instant(value)];
const start = instant(value + 'T00:00:00+09:00');
return [start, start + 86400000 - 1];
}
export function isPublic(doc, now = Date.now()) {
if (!Number.isFinite(now)) throw new Error('Invalid comparison time');
if (!['draft', 'published', 'withdrawn'].includes(doc.status)) throw new Error('Invalid status');
if (doc.publishedAt !== null) contentDate(doc.publishedAt);
if (doc.deletedAt !== null) instant(doc.deletedAt);
const release = doc.publicFrom === null ? null : instant(doc.publicFrom);
return doc.status === 'published' && doc.deletedAt === null
&& release !== null && release <= now;
}
export function documentId(id, origin = 'https://example.com') {
if (typeof id !== 'string' || !/^[a-z0-9-]+$/.test(id)) throw new Error('Invalid id');
return new URL('/#document-' + id, origin).href;
}
export function describeDocument(doc, asOf, origin = 'https://example.com') {
const now = instant(asOf);
// Report stored-data errors even in drafts; determine public availability after validation.
const published = doc.publishedAt === null ? null : bounds(doc.publishedAt);
const modified = doc.contentModifiedAt === null ? null : bounds(doc.contentModifiedAt);
const from = doc.validFrom === null ? null : instant(doc.validFrom);
const until = doc.validUntil === null ? null : instant(doc.validUntil);
if (published && modified && modified[1] < published[0])
throw new Error('Modified before publication');
if (from !== null && until !== null && from >= until) throw new Error('Invalid interval');
if (doc.eventDate !== null) dateOnly(doc.eventDate);
const canonical = canonicalUrl(doc.path, 1, origin);
const id = documentId(doc.id, origin);
if (!isPublic(doc, now)) return null;
if (published && published[0] > now) throw new Error('Future publication');
if (modified && modified[0] > now) throw new Error('Future modification');
const applicability = from === null ? 'unknown'
: now < from ? 'scheduled' : until !== null && now >= until ? 'historical' : 'current';
return {
id, canonical, applicability,
...(published === null ? {} : { datePublished: doc.publishedAt }),
...(modified === null ? {} : { dateModified: doc.contentModifiedAt }),
};
}
export function formatContentDate(value) {
contentDate(value);
// Do not convert date-only values to another time zone.
const day = value.length === 10 ? value : new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(new Date(value));
const [year, month, date] = day.split('-').map(Number);
return `${year}-${month}-${date}`;
}
export const example = {
id: 'hours-2025', path: '/archive/opening-2025', revision: 2,
status: 'published', deletedAt: null,
publicFrom: '2024-12-23T09:00:00+09:00',
createdAt: '2024-12-20T09:00:00+09:00',
publishedAt: '2024-12-23T09:00:00+09:00',
contentModifiedAt: '2026-10-01T09:00:00+09:00',
eventDate: '2024-12-19',
validFrom: '2025-01-01T00:00:00+09:00',
validUntil: '2026-01-01T00:00:00+09:00',
};
describeDocument receives a document and comparison time asOf. It validates ID, address, dates, and periods first, including reporting invalid draft dates. Only valid input reaches isPublic; nonpublic documents return null. Applicability uses a start-inclusive, end-exclusive interval [start, end). An unknown start yields unknown. The returned id, canonical, datePublished, dateModified, and applicability are used together in the body and JSON-LD in the next implementation chapter.
publicFrom is the exact start of public access, independent of publishedAt. contentDate preserves Date and DateTime precision. bounds calculates comparison ranges for date-only values using this library’s Korean calendar, but never stores or displays those boundaries as publication times. Same-day edits are allowed when dates alone cannot establish ordering. Services using another region’s calendar should state their comparison policy too.
If applicability is historical, show a historical-record explanation and the original period. Even if dateModified is today, applicability can remain historical. Calculating these separately prevents a typo correction from turning an old timetable into current guidance. Reusing canonical and date calculations in one place also makes inconsistent addresses or dates in title, body, and JSON-LD easier to find.
Test start and end boundaries and invalid input
Save the next file as identity-time.test.mjs in the same folder and run node --test identity-time.test.mjs. Tests check public availability, applicability boundaries, time zones, and invalid dates and addresses separately.
import test from 'node:test';
import assert from 'node:assert/strict';
import { dateOnly, instant, canonicalUrl, describeDocument, example, isPublic, formatContentDate } from './identity-time.mjs';
const at = '2026-10-23T12:00:00+09:00';
const historical = describeDocument(example, at);
test('Historical records remain public with their own addresses', () => {
assert.equal(historical.applicability, 'historical');
assert.equal(historical.canonical, 'https://example.com/archive/opening-2025');
assert.equal(historical.datePublished, example.publishedAt);
});
test('Include the start and exclude the end of applicability', () => {
const d = { ...example, contentModifiedAt: null };
assert.equal(describeDocument(d, '2024-12-31T23:59:59+09:00').applicability, 'scheduled');
assert.equal(describeDocument(d, d.validFrom).applicability, 'current');
assert.equal(describeDocument(d, d.validUntil).applicability, 'historical');
});
test('Keep drafts, withdrawn, deleted, and not-yet-scheduled items private', () => {
for (const change of [{ status: 'draft' }, { status: 'withdrawn' }, { deletedAt: at },
{ publicFrom: '2027-01-01T00:00:00Z', contentModifiedAt: null }]) {
assert.equal(describeDocument({ ...example, ...change }, at), null);
}
});
test('Do not infer current applicability from an unknown start', () => {
assert.equal(describeDocument({ ...example, validFrom: null }, at).applicability, 'unknown');
});
test('Reject nonexistent dates and timestamps without time zones', () => {
assert.throws(() => instant('2026-02-30T12:00:00+09:00'));
assert.throws(() => instant('2026-10-23T12:00:00'));
assert.throws(() => instant('2026-10-23T25:00:00Z'));
assert.equal(dateOnly('2026-10-23'), '2026-10-23');
});
test('Equivalent time-zone expressions match and event dates remain unchanged', () => {
assert.equal(instant(at), instant('2026-10-23T03:00:00Z'));
assert.equal(dateOnly(example.eventDate), '2024-12-19');
});
test('Reject reversed periods, future modifications, and modifications before publication', () => {
assert.throws(() => describeDocument({ ...example, validUntil: example.validFrom }, at));
assert.throws(() => describeDocument({ ...example, contentModifiedAt: '2027-01-01T00:00:00Z' }, at));
assert.throws(() => describeDocument({ ...example, contentModifiedAt: '2020-01-01T00:00:00Z' }, at));
});
test('Distinguish meaningful page numbers from invalid paths', () => {
assert.equal(canonicalUrl('/guides', 2), 'https://example.com/guides?page=2');
assert.throws(() => canonicalUrl('//elsewhere.example/a'));
assert.throws(() => canonicalUrl('/guides/../admin'));
assert.throws(() => canonicalUrl('/guides', 0));
});
console.log(JSON.stringify(historical, null, 2));
test('Public checks also reject nonexistent calendar dates', () => {
assert.throws(() => isPublic({ ...example, publishedAt: '2026-02-30T09:00:00+09:00' }));
});
test('Report invalid draft dates before excluding from publication', () => {
assert.throws(() => describeDocument({ ...example, status: 'draft', validFrom: 'bad-date' }, at));
});
test('Preserve date-only and unknown publication dates', () => {
const d = { ...example, publishedAt: '2024-12-23', contentModifiedAt: null };
assert.equal(describeDocument(d, at).datePublished, '2024-12-23');
assert.equal(formatContentDate('2024-12-23'), '2024-12-23');
assert.equal(describeDocument({ ...d, publishedAt: null }, at).datePublished, undefined);
assert.equal(describeDocument({ ...d, publicFrom: null }, at), null);
});
test('Keep document ID when its address changes', () => {
const moved = describeDocument({ ...example, path: '/archive/old-hours' }, at);
assert.equal(moved.id, historical.id);
assert.notEqual(moved.canonical, historical.canonical);
});
The output’s applicability is historical and canonical is https://example.com/archive/opening-2025. Dates preserve the input first-publication and substantive modification dates. All 12 tests should pass. If current is returned at the end boundary, check whether the comparison is >=. If 2026-02-30 passes, check calendar-date validation. Accepting timestamps without time zones can make the same string represent different instants depending on the server’s region.
Use the returned publication and applicability states in page output. Publication switches the database revision and updates relevant caches. If a static page must change automatically at a scheduled time, connect a regeneration job for that time. Check already-cached revisions through the update paths in Cache operations.
Make current guidance and historical records readable in the body
Open a historical record with its period and navigation path, such as ‘This timetable applied from January 1 to December 31, 2025. See the following notice for current hours.’ If the machine-readable end is 2026-01-01T00:00:00+09:00, the last applicable calendar day is December 31, 2025. Compare boundary values with visible sentences.
Current guidance states its effective date and exceptions, with publication and modification dates labeled separately. A historical document still titled ‘Current guidance’ misleads readers even if its JSON-LD dates are correct. Edit title, summary, tables, sources, and structured data to describe the same time and conditions.
Preserve verified original dates and sources during republication or migration. If first publication is unknown, do not substitute the new system’s save time. Explain what readers need to interpret the material, such as ‘Original publication date unknown,’ and use only verifiable modification or effective dates.
Preserve date precision and the kind of change
If an old notice says only ‘Published December 23, 2024,’ store publishedAt as 2024-12-23. Adding 00:00:00+09:00 invents an unverified publication time. If even the date is unknown, keep null and omit datePublished. Use a separately verified publicFrom for scheduled server access. Google’s publication-date guidance describes time as optional. Publication-date guidelines
const migrated = {
publishedAt: "2024-12-23", // Date confirmed in the source
publicFrom: "2026-10-24T09:00:00+09:00", // Time the migrated copy becomes public
contentModifiedAt: null, // No substantive modification record
}
// JSON-LD: "datePublished": "2024-12-23"
// Display: Published 2024-12-23
A typo correction and a change in opening hours require different date updates. The following table illustrates publication date, modification date, lastmod, and applicability decisions by change type. Replacing a source link for a core rule also counts as a content change.
Let current institutional introductions and historical activity records describe different times
Suppose an ‘Opening Preparation Committee’ prepared the fictional library in 2025 and a ‘Library Operations Team’ has operated it since 2026. If the home-page title still says ‘Opening Preparation Committee,’ material remains that can answer current questions with the old role. Conversely, changing the signature on 2025 minutes to the operations team alters the historical record. Show the current role in the current introduction, and the period’s role and dates in the minutes.
The body’s modification date explains when information changed. The role’s validity period explains which time-specific questions it can answer. Adding a 2026 follow-up link to 2025 minutes may update dateModified, but the meeting’s role and date remain in 2025. Retrieval that selects only ‘recently modified material’ can confuse the two.
Keep the institutional ID in the CMS and store role, effective start, end, and evidence-document ID separately. Generate current header, introduction, and sharing title from the current role; use the historically stored role for past pages. Include document ID, role, and validity period in internal RAG material too. Connect queries and retrieval conditions so ‘current’ questions find documents valid on the reference date and ‘2025’ questions find documents for that period.