Not especially optimized, but supposed to be clear. This is here so I don't have to always re-figure-out how to do this when i need it.

In prose

Summarized short form, leaving out the nuances of forbidden characters:

Byte rangeAND withSequence length
00-7F (0-127)Nothing1
80-BF (128-191)3F (63)Continuation
C0-DF (192-223)1F (31)2
E0-EF (224-239)0F (15)3
F0-F7 (240-247)07 (7)4
F8-FB (248-251)03 (3)5
FC-FD (252-253)01 (1)6

JavaScript

list is an array of bytes. Returns a string.

function utf8dec(list) {
  let out = "";
  let buf = 0;
  let expecting = 0;
  for (const byte of list) {
    if (expecting > 0) {
      expecting--;
      if (byte >= 0x80 && byte <= 0xBF) {
        const value = byte & 63;
        buf = (buf << 6) + value;
        if (expecting == 0) {
          out += String.fromCodePoint(buf);
          buf = 0;
        }
      } else {
        expecting = 0;
        out += "\uFFFD";
      }
    } else {
      if (byte <= 127) {
        out += String.fromCodePoint(byte);
      } else if (byte <= 0xC0 && byte <= 0xDF) {
        buf = byte & 31;
        expecting = 1;
      } else if (byte >= 0xE0 && byte <= 0xEF) {
        buf = byte & 15;
        expecting = 2;
      } else if (byte >= 0xF0 && byte <= 0xF7) {
        buf = byte & 7;
        expecting = 3;
      } else {
        out += "\uFFFD";
      }
    }
  }
  return out;
}

2020-04-20 | index