Repository: getnikola/nikola

Issue:
lighter-weight fancy dates
"Fancy dates" are currently implemented using [moment.js](https://momentjs.com/), which is well-known for being a comprehensive and robust date-handling library. It's also well-known for being _enormous_ when shipped with all supported locales — 319 kB, probably the size of all the content on my Nikola site put together. :joy: 

Fortunately, as a static site, we only need a few bits of functionality from the wide world of date handling.

# prereq: parsing the date in JavaScript

To get a JavaScript [Date](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date) object, MDN recommends passing an integer timestamp (in milliseconds since the epoch), since that's the hardest for implementations to get wrong. However, using an ISO-8601 formatted string, as Nikola currently produces for `webiso` dates, should be fine too. We don't need any additional client-side dependencies for parsing or validating that format.

# Fanciness 1: using JS_DATE_FORMAT and local user time

Formatting a Date as a local time in _some_ sort of locale-appropriate way is available as [Date.toLocaleString](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toLocaleString). But since, for fanciness purposes, we as the publisher want more control over the formatting, we have to look further.

`Date.toLocaleString` does support options, though the option syntax is more verbose than the strftime-style format used by Moment and less flexible, placing more trust in the browser's internationalization catalogue to do the right thing.

To be able to use strftime-style format strings, consider

- [luxon](https://moment.github.io/luxon/docs/manual/formatting.html#tolocalestring--strings-for-humans-) Luxon is by one of the maintainers of Moment, but relies on the browser's native Intl implementation instead of shipping its own locale files. 
- [date-fns](https://date-fns.org/v1.30.1/docs/format); [locales specified at build time](https://date-fns.org/v1.30.1/docs/I18n) 
- [dayjs](https://github.com/iamkun/dayjs/blob/dev/docs/en/API-reference.md#format-formatstringwithtokens-string) (non-English locales must be [specified at build time](https://github.com/iamkun/dayjs/blob/dev/docs/en/I18n.md))

Some of these libraries are similar in size to Moment, but are more friendly to loading just certain functions (i.e. formatting) without dragging in the whole thing.

# Fanciness 2: using a string like “2 days ago”

This level of fanciness does not yet enjoy native browser support, though see the [TC 39 Proposal for Intl.RelativeTimeFormat](https://github.com/tc39/proposal-intl-relative-time). Polyfill may be available.

- [luxon](https://moment.github.io/luxon/docs/class/src/datetime.js~DateTime.html#instance-method-toRelative); internationalization depends on Intl.RelativeTimeFormat, will fall back to English.
- [date-fns](https://date-fns.org/v1.30.1/docs/distanceInWordsToNow)
- [dayjs RelativeTime plug-in](https://github.com/iamkun/dayjs/blob/dev/docs/en/Plugin.md#relativetime)
- [timeago](https://timeago.org/) 

# Other Considerations

- What's Nikola's browser support policy? Does it include any [browsers that don't support Internationalization](https://caniuse.com/#feat=internationalization)? Would it be okay if those browsers fall back to non-fancy dates?
- JavaScript build process. The extent to which we can deliver a smaller payload than Moment may depend on having a modern bundler; if Nikola's javascript dependencies are limited to "prebuilt things" (as mentioned in https://github.com/getnikola/nikola/pull/2965#issue-169454509 ), that'll rule out some of the alternatives.

Related: 
 - #2183 (which is currently titled "Unnecessary .js includes" but only cites moment.


Discussion:
> * What's Nikola's browser support policy? Does it include any [browsers that don't support Internationalization](https://caniuse.com/#feat=internationalization)?

We currently don’t have any specific policy for browser support. Note that while Level 1 will probably be supported correctly, Level 2 won’t, as [RelativeTimeFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RelativeTimeFormat#Browser_compatibility) was added to Chrome (stable) in December 2018, and to Firefox in January 2019. It’s not supported by Safari.

A good rule-of-thumb that I would personally approve would be that a JS feature may be used, if it’s supported:
* by stable versions of Chrome, Firefox and Safari
* on desktop and mobile
* for at least 3 months

> Would it be okay if those browsers fall back to non-fancy dates?

As long as the number of affected users is small, sure.

> * JavaScript build process. The extent to which we can deliver a smaller payload than Moment may depend on having a modern bundler; if Nikola's javascript dependencies are limited to "prebuilt things" (as mentioned in [#2965 (comment)](https://github.com/getnikola/nikola/pull/2965#issue-169454509) ), that'll rule out some of the alternatives.

Nikola will *never* require Node to be installed for the core feature set. [The Node ecosystem is terrible](https://chriswarrick.com/blog/2019/02/15/modern-web-development-where-you-need-500-packages-to-build-bootstrap/), and external dependencies are always a pain to get working.

Another possible solution to minimize asset size would be to re-use the infrastructure previously used with Colorbox (copy only required locales to output, load only one i18n file on a page): https://github.com/getnikola/nikola/commit/f45bafbfefcc6c47387c057f28cfae18e86541f8 — cdnjs [supports that workflow](https://cdnjs.com/libraries/moment.js/) as well, even if we wouldn’t be able to use SRI for these files.
Day.Js is a nice alternative to me. It's supper small, only 2kb with locale and format.

⏰ Day.js 2KB immutable date library alternative to Moment.js with the same modern API

https://github.com/iamkun/dayjs
> 
> 
> Day.Js is a nice alternative to me. It's supper small, only 2kb with locale and format.
> 
> ⏰ Day.js 2KB immutable date library alternative to Moment.js with the same modern API
> 
> https://github.com/iamkun/dayjs

Not sure if it's used by nikola, but it doesn't look like dayjs has timezone support
Looks like relative dates are there except for Safari & edge.

https://caniuse.com/#feat=mdn-javascript_builtins_intl_relativetimeformat

Would that be acceptable @Kwpolska ?
@ralsina 

1. Safari is kinda popular, and is the only available browser on iOS.
2. Safari is also a terrible browser to work with these days.
3. At the same time, the feature is very minor, so even if we fall back to a “regular” date, that shouldn’t be too much of an issue.
4. We could switch to the built-in browser support. For Safari, if we find a polyfill that does it well enough, we could use that.

I’m going to take a look at this, probably using luxon, because I don’t feel like messing with date parsing in JS by foot.

Resolving patch:
diff --git a/nikola/nikola.py b/nikola/nikola.py
--- a/nikola/nikola.py
+++ b/nikola/nikola.py
@@ -168,6 +168,61 @@
         'sr_latin': 'sr_Latn',
     },
     'RTL_LANGUAGES': ('ar', 'fa', 'he', 'ur'),
+    'LUXON_LOCALES': {
+        'af': 'af',
+        'ar': 'ar',
+        'az': 'az',
+        'bg': 'bg',
+        'bn': 'bn',
+        'bs': 'bs',
+        'ca': 'ca',
+        'cs': 'cs',
+        'cz': 'cs',
+        'da': 'da',
+        'de': 'de',
+        'el': 'el',
+        'en': 'en',
+        'eo': 'eo',
+        'es': 'es',
+        'et': 'et',
+        'eu': 'eu',
+        'fa': 'fa',
+        'fi': 'fi',
+        'fr': 'fr',
+        'gl': 'gl',
+        'hi': 'hi',
+        'he': 'he',
+        'hr': 'hr',
+        'hu': 'hu',
+        'id': 'id',
+        'it': 'it',
+        'ja': 'ja',
+        'ko': 'ko',
+        'lt': 'lt',
+        'ml': 'ml',
+        'nb': 'nb',
+        'nl': 'nl',
+        'pa': 'pa',
+        'pl': 'pl',
+        'pt': 'pt',
+        'pt_br': 'pt-BR',
+        'ru': 'ru',
+        'sk': 'sk',
+        'sl': 'sl',
+        'sq': 'sq',
+        'sr': 'sr-Cyrl',
+        'sr_latin': 'sr-Latn',
+        'sv': 'sv',
+        'te': 'te',
+        'tr': 'tr',
+        'th': 'th',
+        'uk': 'uk',
+        'ur': 'ur',
+        'vi': 'vi',
+        'zh_cn': 'zh-CN',
+        'zh_tw': 'zh-TW'
+    },
+    # TODO: remove in v9
     'MOMENTJS_LOCALES': {
         'af': 'af',
         'ar': 'ar',
@@ -426,7 +481,8 @@ def __init__(self, **config):
             'DISABLE_INDEXES': False,
             'DISABLE_MAIN_ATOM_FEED': False,
             'DISABLE_MAIN_RSS_FEED': False,
-            'JS_DATE_FORMAT': 'YYYY-MM-DD HH:mm',
+            'MOMENTJS_DATE_FORMAT': 'YYYY-MM-DD HH:mm',
+            'LUXON_DATE_FORMAT': {},
             'DATE_FANCINESS': 0,
             'DEFAULT_LANG': "en",
             'DEPLOY_COMMANDS': {'default': []},
@@ -634,7 +690,8 @@ def __init__(self, **config):
                                       'ATOM_FILENAME_BASE',
                                       'AUTHOR_PATH',
                                       'DATE_FORMAT',
-                                      'JS_DATE_FORMAT',
+                                      'LUXON_DATE_FORMAT',
+                                      'MOMENTJS_DATE_FORMAT',  # TODO: remove in v9
                                       'RSS_COPYRIGHT',
                                       'RSS_COPYRIGHT_PLAIN',
                                       # Issue #2970
@@ -652,6 +709,7 @@ def __init__(self, **config):
                                              'extra_head_data',
                                              'date_format',
                                              'js_date_format',
+                                             'luxon_date_format',
                                              'front_index_header',
                                              'theme_config',
                                              )
@@ -664,13 +722,27 @@ def __init__(self, **config):
         # WARNING: navigation_(alt_)links SHOULD NOT be added to the list above.
         #          Themes ask for [lang] there and we should provide it.
 
-        # We first have to massage JS_DATE_FORMAT, otherwise we run into trouble
+        # Luxon setup is a dict of dicts, so we need to set up the default here.
+        if not self.config['LUXON_DATE_FORMAT']:
+            self.config['LUXON_DATE_FORMAT'] = {self.config['DEFAULT_LANG']: {'preset': False, 'format': 'yyyy-MM-dd HH:mm'}}
+        # TODO: remove Moment.js stuff in v9
         if 'JS_DATE_FORMAT' in self.config:
-            if isinstance(self.config['JS_DATE_FORMAT'], dict):
-                for k in self.config['JS_DATE_FORMAT']:
-                    self.config['JS_DATE_FORMAT'][k] = json.dumps(self.config['JS_DATE_FORMAT'][k])
+            utils.LOGGER.warning("Moment.js was replaced by Luxon in the default themes, which uses different date formats.")
+            utils.LOGGER.warning("If you’re using a built-in theme, set LUXON_DATE_FORMAT. If your theme uses Moment.js, you can silence this warning by renaming JS_DATE_FORMAT to MOMENTJS_DATE_FORMAT.")
+            utils.LOGGER.warning("Sample Luxon config: LUXON_DATE_FORMAT = " + str(self.config['LUXON_DATE_FORMAT']))
+            self.config['MOMENTJS_DATE_FORMAT'] = self.config['LUXON_DATE_FORMAT']
+
+        # We first have to massage MOMENTJS_DATE_FORMAT and LUXON_DATE_FORMAT, otherwise we run into trouble
+        if 'MOMENTJS_DATE_FORMAT' in self.config:
+            if isinstance(self.config['MOMENTJS_DATE_FORMAT'], dict):
+                for k in self.config['MOMENTJS_DATE_FORMAT']:
+                    self.config['MOMENTJS_DATE_FORMAT'][k] = json.dumps(self.config['MOMENTJS_DATE_FORMAT'][k])
             else:
-                self.config['JS_DATE_FORMAT'] = json.dumps(self.config['JS_DATE_FORMAT'])
+                self.config['MOMENTJS_DATE_FORMAT'] = json.dumps(self.config['MOMENTJS_DATE_FORMAT'])
+
+        if 'LUXON_DATE_FORMAT' in self.config:
+            for k in self.config['LUXON_DATE_FORMAT']:
+                self.config['LUXON_DATE_FORMAT'][k] = json.dumps(self.config['LUXON_DATE_FORMAT'][k])
 
         for i in self.TRANSLATABLE_SETTINGS:
             try:
@@ -1172,7 +1244,10 @@ def _set_global_context_from_config(self):
             'SHOW_SOURCELINK')
         self._GLOBAL_CONTEXT['extra_head_data'] = self.config.get('EXTRA_HEAD_DATA')
         self._GLOBAL_CONTEXT['date_fanciness'] = self.config.get('DATE_FANCINESS')
-        self._GLOBAL_CONTEXT['js_date_format'] = self.config.get('JS_DATE_FORMAT')
+        self._GLOBAL_CONTEXT['luxon_locales'] = LEGAL_VALUES['LUXON_LOCALES']
+        self._GLOBAL_CONTEXT['luxon_date_format'] = self.config.get('LUXON_DATE_FORMAT')
+        # TODO: remove in v9
+        self._GLOBAL_CONTEXT['js_date_format'] = self.config.get('MOMENTJS_DATE_FORMAT')
         self._GLOBAL_CONTEXT['momentjs_locales'] = LEGAL_VALUES['MOMENTJS_LOCALES']
         # Patch missing locales into momentjs defaulting to English (Issue #3216)
         for l in self._GLOBAL_CONTEXT['translations']:
diff --git a/scripts/langmatrix.py b/scripts/langmatrix.py
--- a/scripts/langmatrix.py
+++ b/scripts/langmatrix.py
@@ -2,8 +2,8 @@
 """A matrix! Of languages!"""
 import nikola.nikola
 
-keys = ['MOMENTJS_LOCALES', 'PYPHEN_LOCALES', 'DOCUTILS_LOCALES']
-keys_short = ['language', 'moment', 'pyphen', 'docutils']
+keys = ['LUXON_LOCALES', 'MOMENTJS_LOCALES', 'PYPHEN_LOCALES', 'DOCUTILS_LOCALES']
+keys_short = ['language', 'luxon', 'moment', 'pyphen', 'docutils']
 print('\t'.join(keys_short))
 
 for tr in nikola.nikola.LEGAL_VALUES['TRANSLATIONS']:


Test patch: