Short URLs

The idea isn’t mine:

http://chris.beams.io/posts/epoch/

But I like it – and it would be easy to implement in Hugo.

I thought about just adding an alias directly in the content generator, but I agree with Chris Beams in the post above; it is better with a dedicated field.

So to sum it up:

  • Add a timestamp field that defaults to epoch minutes on content creation time
  • Add some knobs:
    • Turn this timestamp feature on/off
    • Use epoch seconds instead of minutes
    • Add a custom epoch (for personal blogs, this could enable having a post about your birth with timestamp=0)

Any thoughts?

1 Like

I think it’s an idea with some merit.

Some suggestions:

  1. Make it optional
  2. Use seconds
  3. Use Base 36 encoding, not base 10.
    It’s a lot shorter and still works on case insensitive file systems.
  4. Tie it into the hugo new functionality.
  5. Provide a page.ShortLink() function
  6. Add documentation :slight_smile:

Digging this one up, because I’m also looking for a (semi-)automated method for creating shortlinks.

Recently came across this page: permashortlink - IndieWeb
Really liking the idea of the NewBase60.

Multiple implementations exist.

  1. Purely time-based

Use NB60 to turn yyyy-mm-dd into a number and hh: mm: ss into a number. Concat both together.
Now 4xgNaa expands to /2018/11/26/173535/

  1. using identifiers and sequence numbers

You basically use NB60 to turn /year/month/day into a number, then append e.g. a sequence number (in case you have more than 1 post per day) and/or a type identifier (blog, article, note, …).
So tSSSn can programmatically be expanded into /type/year/month/day/number

I see permashortlink is also mentioned here: Multiple permalinks

The existing hash.FNV32a function gets you pretty close.

layouts/partials/make-short-url.html
{{ $html := printf `<!DOCTYPE html>
<html lang="%[1]s">
  <head>
    <title>%[2]s</title>
    <link rel="canonical" href="%[2]s">
    <meta name="robots" content="noindex">
    <meta charset="utf-8">
    <meta http-equiv="refresh" content="0; url=%[2]s">
  </head>
</html>
` .Language.LanguageCode .Permalink }}

{{ $hash := .Path | hash.FNV32a }}
{{ $publishPath := path.Join site.LanguagePrefix $hash "index.html" }}
{{ (resources.FromString $publishPath $html).Publish }}
{{ return ($hash | absLangURL) }}

Then call the partial from your base template or single template or whatever.

{{ $shortURL := partial "make-short-url.html" . }}
<p>Short URL: <a href="{{ $shortURL }}">{{ $shortURL }}</a></p>

Every time the partial is called, it writes an HTML redirect file to disk, something like:

public/4042937710/index.html
<!DOCTYPE html>
<html lang="en-US">
  <head>
    <title>http://example.org/posts/post-1/</title>
    <link rel="canonical" href="http://example.org/posts/post-1/">
    <meta name="robots" content="noindex">
    <meta charset="utf-8">
    <meta http-equiv="refresh" content="0; url=http://example.org/posts/post-1/">
  </head>
</html>

On a monolingual site the permalink looks like this:

http://example.org/4042937710

On a multilingual multi-host site, with defaultContentLanguageInSubdir = true, the permalinks look like this:

http://example.org/en/4042937710
http://example.org/de/4042937710

On a multilingual multi-host site, with defaultContentLanguageInSubdir = false, the permalinks look like this:

http://example.org/4042937710
http://example.org/de/4042937710

The hash is based on a page’s logical path, so the shortened URL is not affected by changes to:

  • The title, date, slug, or url front matter fields
  • The permalinks key in your site configuration

This approach will not work if uglyURLs = true in your site configuration, but I suppose you could modify the partial to accommodate that.

5 Likes

This does seem to work :slight_smile:

But I feel like alias: would somehow be a neater solution for this?

Sure, you could use an archetype to automatically populate the aliases array in front matter, but:

  1. That only works when you create content with the hugo new content command. It won’t work when someone creates a new content file by copying an existing content file.
  2. It is fragile. Anyone can change the value at any time. It is far less likely that someone would change the logical path, and easier to catch in a PR.
  3. The front matter approach is not possible when pulling content in from a module unless the module content already has the aliases defined.

Thanks @jmooring for the detailed instructions. I found the following minor change in make-short-url.html useful (more dense and controllable hash length):

{{ $hash := .Path | md5 }}
{{ $hash := slicestr $hash 0 12 }}

I haven’t found a way to make it even more dense with base64 encoding. Would that be possible?

The hashing I suggested (hash.FNV32a) creates a 10 digit int. Your example above is longer than that, so I’m not sure where you’re going with this.

Alternatively, you could use hash.XxHash (which is really fast) then take the first 8 chars… or something similar. The probability of a collision with something like 10,000 pages is really low, and you could bake a collision check into the partial.

Indeed, 12 chars is longer, sorry. Thanks for suggesting XxHash because I overlooked that possibility. (I’m new to Hugo.)

Do you know of an example on how to detect collisions as you suggest? (This is just out of curiosity. The odds are currently extremely low as I’m just playing with a few pages.)

Generate the alias files from the base template wrapped within an IsHome conditional to make sure they’re only generated once per site. In the loop, store the hash in an array and compare each new hash to the elements of the array.

Thanks again! I think it works. I’ll post below in case it is useful for anyone.

I’ve added a calc-short-url.html to reuse the hash calculation code and with an optional manual override to deal with collisions:

{{/* Get page info */}}
{{ $path := .Path }}
{{ $hashOverride := .Params.shortpath }}

{{/* Use the hashOverride if it exists, create a new hash */}}
{{ $shortHash := "" }}
{{ if $hashOverride }}
  {{ $shortHash = $hashOverride }}
{{ else }}
  {{ $fullHash := hash.XxHash $path }}
  {{ $shortHash = slicestr $fullHash 0 8 }}
{{ end }}

{{ return $shortHash }}

In the base template, I’m checking for collisions as follows:

{{ if .IsHome }}
  {{ $hashes := newScratch }}
  {{ range .Site.Pages }}
    {{ $hash := partial "calc-short-url.html" . }}
    
    {{/* Check for collisions */}}
    {{ if $hashes.Get $hash }}
      {{ errorf "Collision detected for %s and %s" .Path ($hashes.Get $hash) }}
    {{ else }}
      {{ $hashes.Set $hash .Path }}
    {{ end }}
  {{ end }}
{{ end }}

In make-short-url.html, the hash can be computed in the same way:

{{ $hash := partial "calc-short-url.html" . }}

Looking back at it, the shortpath has merit beyond avoiding collisions. It’s nice to be able to make a semantic short URL.

1 Like