@port60/template-kit 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -50,11 +50,27 @@ See the [AI quickstart](https://developers.port60.com/guides/ai-quickstart/).
50
50
 
51
51
  ## The contract
52
52
 
53
- Kit 1.0.0 authors **port60-liquid@2**, content model **2.0**, using the contract under
53
+ Kit 1.1.0 authors **port60-liquid@2**, content model **2.0**, using the contract under
54
54
  `src/vendor/contract/v2`. Collection envelopes, independent header/footer navigation,
55
55
  resolved actions and page-scoped sections are explicit. V1 sources need a deliberate migration,
56
56
  not a manifest-only relabel. Historical v1 contracts remain frozen for existing platform pins.
57
57
 
58
+ ### Added in 1.1.0
59
+
60
+ The v2 dialect adds `t` and `local_date`, matching the compatible platform host. Use
61
+ `t: site.locale.code` only for supported interface phrases, not tenant-authored content.
62
+ The initial phrase catalogue is Arabic; unknown phrases or languages preserve the supplied
63
+ text. `local_date` formats English, Welsh or Arabic Gregorian dates in UTC with fixed
64
+ `date` and `datetime` styles. It does not convert the tenant's prayer wall-clock times.
65
+
66
+ Schedule rows can carry stable `key` values for prayer icons, independently of their supplied
67
+ display names. Shared mobile island styling also uses logical offsets for both directions.
68
+ Historical v1 filters and contracts are unchanged. These additions do not imply complete
69
+ Arabic live-island, typography or legal-copy support.
70
+
71
+ Deploy the compatible host before uploading templates that use the new filters. Kit 1.0.0
72
+ will correctly reject them. See the [localisation guide](https://developers.port60.com/guides/localisation/).
73
+
58
74
  ## Designer previews
59
75
 
60
76
  The scaffold creates `preview/config.json` and `preview/media/`. Optional `content` points to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@port60/template-kit",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Build Port60 site templates locally: scaffold, live-preview, validate against the platform contract, and package for studio upload. AI-agent ready: every scaffold ships AGENTS.md and validate emits machine-readable JSON.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -60,7 +60,7 @@ export function create(args) {
60
60
  }, null, 2) + '\n');
61
61
  writeFileSync(join(target, 'README.md'), `# ${label}
62
62
 
63
- A Port60 site template using \`port60-liquid@2\`, content model \`2.0\` and kit \`1.0.0\`.
63
+ A Port60 site template using \`port60-liquid@2\`, content model \`2.0\` and kit \`${KIT_PACKAGE.version}\`.
64
64
  Existing v1 platform pins keep historical support; this kit accepts v2 authoring only.
65
65
  Start with \`npm install\`, then:
66
66
 
@@ -1,3 +1,7 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ const KIT_VERSION = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
4
+
1
5
  // A contract briefing shared by every generated coding-agent instruction file.
2
6
  export function agentsMd(name) {
3
7
  return `# Working on the "${name}" Port60 template
@@ -8,7 +12,7 @@ identity, authored content and inline editing markers while changing presentatio
8
12
 
9
13
  ## Versions and iteration
10
14
 
11
- Use format port60-liquid@2, content model2.0 and kit1.0.0. Existing v1 platform pins retain
15
+ Use format port60-liquid@2, content model 2.0 and kit ${KIT_VERSION}. Existing v1 platform pins retain
12
16
  their historical contract; this kit explicitly rejects v1 for new authoring and uploads.
13
17
  Never relabel v1 without migrating its reads. Published name/version identities are immutable.
14
18
 
@@ -98,6 +102,11 @@ retains its field marker; resolved fallback actions do not.
98
102
  not a transfer of route ownership or tenant entitlements.
99
103
  - Hero photos need a palette scrim, a carousel for multiple photos and a designed no-photo state.
100
104
  - Preserve settings and Looks. Use platform fonts and declared CSS tokens.
105
+ - The host owns lang/dir and Arabic fonts. Use logical CSS properties. Read site.locale.code.
106
+ - The next coordinated kit release adds t for supported interface phrases and local_date for
107
+ fixed Gregorian date/datetime formatting in UTC. Do not use these on registry kit 1.0.0.
108
+ Never translate authored text, infer prayer identities from translated names or convert
109
+ supplied prayer wall-clock strings. Details: /guides/localisation/ in the developer docs.
101
110
  - Scope behaviour-dependent hidden content under .p60-js so no-JavaScript stays readable.
102
111
  - Keep loops bounded. Test empty collections, cleared text and unknown enum values.
103
112
 
@@ -746,7 +746,7 @@
746
746
  "since": "2.0",
747
747
  "cap": 4,
748
748
  "moreHref": null,
749
- "description": "TYPE-DERIVED recurring schedules. `type` is an OPEN enum; 'worship' is the first (its shape is the layout worship contract: times, labels, today, next). Other institutions arrive as new types; render the types you know, ignore the rest.",
749
+ "description": "TYPE-DERIVED recurring schedules. `type` is an OPEN enum; 'worship' is the first (times, labels, today, next, jumah). Mosque daily times and next may carry a stable `key`: fajr, zuhr, asr, maghrib or isha. Use key for icons, never replace the supplied name. Missing/unknown keys remain readable without an icon. Friday rows are `jumah`, not `jumuah`. Other institutions arrive as new types; render the types you know, ignore the rest.",
750
750
  "item": {
751
751
  "type": {
752
752
  "type": "string",
@@ -88,7 +88,9 @@
88
88
  "upcase",
89
89
  "url_decode",
90
90
  "url_encode",
91
- "where"
91
+ "where",
92
+ "t",
93
+ "local_date"
92
94
  ],
93
95
  "excluded": {
94
96
  "tags": [
@@ -92,7 +92,9 @@
92
92
  "upcase",
93
93
  "url_decode",
94
94
  "url_encode",
95
- "where"
95
+ "where",
96
+ "t",
97
+ "local_date"
96
98
  ],
97
99
  "excluded": {
98
100
  "tags": [
@@ -2596,7 +2598,7 @@
2596
2598
  "since": "2.0",
2597
2599
  "cap": 4,
2598
2600
  "moreHref": null,
2599
- "description": "TYPE-DERIVED recurring schedules. `type` is an OPEN enum; 'worship' is the first (its shape is the layout worship contract: times, labels, today, next). Other institutions arrive as new types; render the types you know, ignore the rest.",
2601
+ "description": "TYPE-DERIVED recurring schedules. `type` is an OPEN enum; 'worship' is the first (times, labels, today, next, jumah). Mosque daily times and next may carry a stable `key`: fajr, zuhr, asr, maghrib or isha. Use key for icons, never replace the supplied name. Missing/unknown keys remain readable without an icon. Friday rows are `jumah`, not `jumuah`. Other institutions arrive as new types; render the types you know, ignore the rest.",
2600
2602
  "item": {
2601
2603
  "type": {
2602
2604
  "type": "string",
@@ -5,6 +5,8 @@
5
5
  // JSON); this module does no file loading.
6
6
 
7
7
  /** Marker protocol for island placement: the island tag emits these; the renderer splits on them. */
8
+ import { templateMessage, templateDate } from './locale.mjs';
9
+
8
10
  export const ISLAND_MARK_START = '\u0000P60_ISLAND:';
9
11
  export const ISLAND_MARK_END = '\u0000';
10
12
 
@@ -31,6 +33,8 @@ export function configureDialect(liquid, dialect, islandNames) {
31
33
  }
32
34
  // Filters LiquidJS ships beyond our whitelist (e.g. its non-standard json/inspect debug filters).
33
35
  const allowed = new Set(dialect.filters);
36
+ if (allowed.has('t')) liquid.registerFilter('t', templateMessage);
37
+ if (allowed.has('local_date')) liquid.registerFilter('local_date', templateDate);
34
38
  for (const name of ['json', 'inspect', 'to_integer', 'normalize_whitespace', 'find', 'find_exp', 'group_by', 'group_by_exp', 'where_exp', 'sum']) {
35
39
  if (!allowed.has(name)) {
36
40
  liquid.registerFilter(name, () => {
@@ -0,0 +1,74 @@
1
+ // Template interface copy only. Never translate tenant-authored content or source record labels.
2
+ // Plain JS so the host and kit use the exact same implementation, without network or tenant state.
3
+ const arabic = Object.freeze({
4
+ 'Explore': 'تصفّح', 'Menu': 'القائمة', 'Open menu': 'فتح القائمة', 'Close menu': 'إغلاق القائمة',
5
+ 'Skip to content': 'انتقل إلى المحتوى', 'Skip to main content': 'انتقل إلى المحتوى الرئيسي',
6
+ 'Search the website': 'ابحث في الموقع', 'Search this site': 'ابحث في الموقع',
7
+ 'Main': 'التنقل الرئيسي', 'Footer': 'تذييل الصفحة', 'Social links': 'حسابات التواصل',
8
+ 'Cookies': 'ملفات تعريف الارتباط', 'Cookie settings': 'إعدادات ملفات تعريف الارتباط',
9
+ 'Previous image': 'الصورة السابقة', 'Next image': 'الصورة التالية',
10
+ 'Previous campaign': 'الحملة السابقة', 'Next campaign': 'الحملة التالية',
11
+ 'At the mosque': 'في المسجد', 'Prayer times': 'مواقيت الصلاة',
12
+ 'Prayer and Jumuah times': 'مواقيت الصلاة والجمعة', 'Prayer schedule': 'مواقيت الصلاة',
13
+ 'Prayer timetable': 'جدول الصلاة', 'Daily prayer timetable': 'جدول الصلوات اليومية',
14
+ 'Daily prayer times': 'مواقيت الصلوات اليومية', 'Daily prayers': 'الصلوات اليومية',
15
+ 'Prayer': 'الصلاة', 'Jumuah': 'الجمعة', 'Friday prayer': 'صلاة الجمعة',
16
+ 'Friday at the mosque': 'الجمعة في المسجد', 'Prayer information': 'معلومات الصلاة',
17
+ 'Full timetable': 'الجدول الكامل', 'Next:': 'الصلاة القادمة:',
18
+ 'Begins': 'بداية الوقت', 'Congregation': 'الجماعة', 'Jama’at': 'الجماعة',
19
+ 'Read article': 'اقرأ الخبر', 'Read more': 'اقرأ المزيد', 'Find out more': 'اعرف المزيد',
20
+ 'Event details': 'تفاصيل الفعالية', 'View event': 'عرض الفعالية',
21
+ 'View course': 'عرض الدورة', 'Explore course': 'اكتشف الدورة',
22
+ 'View campaign': 'عرض الحملة', 'Explore campaign': 'اكتشف الحملة',
23
+ 'Support this campaign': 'ادعم هذه الحملة', 'Online': 'عبر الإنترنت',
24
+ 'Tickets not on sale': 'التذاكر غير متاحة للبيع', 'Sold out': 'اكتمل العدد',
25
+ 'Free': 'مجاناً', 'From': 'من', 'Registration': 'التسجيل', 'Tickets': 'تذاكر',
26
+ 'Get tickets': 'احجز تذكرتك', 'Book a place': 'احجز مكانك',
27
+ 'Download': 'تنزيل', 'Read document': 'اقرأ المستند', 'View document': 'عرض المستند',
28
+ 'All articles': 'جميع الأخبار', 'All events': 'جميع الفعاليات', 'All courses': 'جميع الدورات',
29
+ 'All campaigns': 'جميع الحملات', 'All services': 'جميع الخدمات',
30
+ 'Article': 'خبر', 'Course': 'دورة', 'Campaign': 'حملة', 'Service': 'خدمة',
31
+ 'Article unavailable': 'الخبر غير متاح', 'Course unavailable': 'الدورة غير متاحة',
32
+ 'This article is not available to view.': 'هذا الخبر غير متاح للعرض.',
33
+ 'This course is not available to view.': 'هذه الدورة غير متاحة للعرض.',
34
+ 'Browse': 'تصفّح', 'Browse courses': 'تصفّح الدورات', 'Back to': 'العودة إلى',
35
+ 'Availability': 'التوفّر', 'Course details': 'تفاصيل الدورة', 'Course options': 'خيارات الدورة',
36
+ 'Course enrolment': 'التسجيل في الدورة', 'Open article photograph': 'فتح صورة الخبر',
37
+ 'Children': 'الأطفال', 'Adults': 'البالغون', 'Families': 'العائلات', 'Everyone': 'الجميع',
38
+ 'Starts': 'البداية', 'Ends': 'النهاية', 'Monthly': 'شهرياً', 'One payment': 'دفعة واحدة',
39
+ 'No places currently available': 'لا توجد أماكن متاحة حالياً', 'Not supplied': 'غير محدد',
40
+ 'Payment': 'الدفع', 'Price': 'السعر', 'Term fee': 'رسوم الفصل', 'Updated': 'آخر تحديث',
41
+ 'Where': 'المكان', 'Who it is for': 'الفئة المستهدفة', 'Open document': 'فتح المستند',
42
+ 'Word document': 'مستند Word', 'Support this appeal': 'ادعم هذا النداء',
43
+ 'View available sessions and enrolment details.': 'اطّلع على الجلسات المتاحة وتفاصيل التسجيل.',
44
+ 'min read': 'دقائق للقراءة', '% of target reached': '% من الهدف',
45
+ });
46
+
47
+ export const templateMessageKeys = Object.freeze(Object.keys(arabic));
48
+ const language = value => typeof value === 'string' && value.length <= 35
49
+ ? value.toLowerCase().split('-')[0] : 'en';
50
+
51
+ /** Unknown copy/languages retain the supplied English UI fallback. Output remains autoescaped. */
52
+ export function templateMessage(message, locale) {
53
+ if (typeof message !== 'string') return '';
54
+ return language(locale) === 'ar' && Object.hasOwn(arabic, message) ? arabic[message] : message;
55
+ }
56
+
57
+ // Exactly six formatter instances at most. No per-tenant or arbitrary-pattern cache growth.
58
+ const formatters = new Map();
59
+ export function templateDate(value, locale, style = 'date') {
60
+ // A timestamp needs an explicit offset. Date.parse on a zone-less timestamp would depend
61
+ // on the kit author's machine rather than the host's UTC rendering contract.
62
+ if (typeof value !== 'string' || value.length > 64 || !/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:\d{2}))?$/.test(value)) return '';
63
+ const timestamp = Date.parse(value);
64
+ if (!Number.isFinite(timestamp) || !['date', 'datetime'].includes(style)) return '';
65
+ const day = value.slice(0, 10);
66
+ if (new Date(Date.parse(`${day}T00:00:00Z`)).toISOString().slice(0, 10) !== day) return '';
67
+ const lang = ['ar', 'cy'].includes(language(locale)) ? language(locale) : 'en-GB';
68
+ const key = `${lang}:${style}`;
69
+ if (!formatters.has(key)) formatters.set(key, new Intl.DateTimeFormat(lang, {
70
+ calendar: 'gregory', timeZone: 'UTC', day: '2-digit', month: 'long', year: 'numeric',
71
+ ...(style === 'datetime' ? { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' } : {}),
72
+ }));
73
+ return formatters.get(key).format(timestamp);
74
+ }
@@ -1064,11 +1064,10 @@ textarea.p60-form-control {
1064
1064
  escapes ANY wrapper depth; layouts here are symmetric, so the sheet lands exactly 0..100vw. */
1065
1065
  @media (max-width: 640px) {
1066
1066
  .donate-card {
1067
- width: 100vw;
1068
- margin-left: calc(50% - 50vw);
1067
+ inline-size: 100vw;
1068
+ margin-inline-start: calc(50% - 50vw);
1069
1069
  border-radius: 0;
1070
- border-left: 0;
1071
- border-right: 0;
1070
+ border-inline: 0;
1072
1071
  padding: 1.4rem 1.1rem;
1073
1072
  }
1074
1073
  }
@@ -2368,14 +2367,14 @@ html:has(.donation-giftaid-dialog[open]) { overflow: hidden; }
2368
2367
  @media (max-width: 640px) {
2369
2368
  .article-grid {
2370
2369
  display: flex;
2371
- width: 100vw;
2372
- margin-left: calc(50% - 50vw);
2370
+ inline-size: 100vw;
2371
+ margin-inline-start: calc(50% - 50vw);
2373
2372
  overflow-x: auto;
2374
2373
  -webkit-overflow-scrolling: touch;
2375
2374
  scroll-snap-type: x proximity;
2376
2375
  gap: 0.9rem;
2377
2376
  padding: 0.2rem 22px 0.6rem;
2378
- scroll-padding-left: 22px;
2377
+ scroll-padding-inline-start: 22px;
2379
2378
  scrollbar-width: none;
2380
2379
  }
2381
2380
  .article-grid::-webkit-scrollbar { display: none; }
@@ -2779,8 +2778,8 @@ html:has(.donation-giftaid-dialog[open]) { overflow: hidden; }
2779
2778
  @media (max-width: 640px) {
2780
2779
  .events-carousel {
2781
2780
  position: relative;
2782
- width: 100vw;
2783
- margin-left: calc(50% - 50vw);
2781
+ inline-size: 100vw;
2782
+ margin-inline-start: calc(50% - 50vw);
2784
2783
  gap: 0;
2785
2784
  }
2786
2785
  .events-carousel .carousel-slide { border-radius: 0; }
@@ -5031,8 +5030,8 @@ html:has(.donation-giftaid-dialog[open]) { overflow: hidden; }
5031
5030
  Cooperative gestures keep page-scrolling safe over the full-width canvas. */
5032
5031
  @media (max-width: 640px) {
5033
5032
  .impact-map-stage {
5034
- width: 100vw;
5035
- margin-left: calc(50% - 50vw);
5033
+ inline-size: 100vw;
5034
+ margin-inline-start: calc(50% - 50vw);
5036
5035
  border-radius: 0;
5037
5036
  }
5038
5037
  }