Hugo Sales

Full stack developer / FOSS contributor

Career

Experience

  1. to present

    Backend web developer at Runtime Revolution

    Backend development in Ruby on Rails at Runtime Revolution, a Lisbon company that builds software for its clients

  2. to

    Full stack web developer at Visrez, Ireland, remote

    • A Vue and Three.js app that lays an event space out in 3D. Blocks of tables and chairs fill a drawn area and guests get seats.
    • A React map of the resort with custom pins, a virtual tour and step by step wayfinding.
    • The Ruby backend behind both. 3D model orders, subscriptions, Google and Apple sign-in and a library of about 11,000 3D objects.

    See Demos

  3. to

    European Innovation Academy finalist at ProtoSyn

    A startup enabling researchers to design and run protein folding simulations without requiring coding expertise. A node based editor in Vue and TypeScript wires up inputs, algorithms, outputs and plots, and the app writes the Julia that runs them

  4. to

    Core maintainer at GNU social

    The v3 rewrite of the federated social network, in PHP. An event driven modular architecture, low system requirements, and a deployment a sysadmin can run without much time for it

    See GNU social v3

  5. to

    Software engineering intern at Leb Mobility

    Brought an acquired ride sharing platform's PHP backend up to date and its iOS and Android apps onto current versions, then built a driver and vehicle management platform with a React Native app

  6. to

    Table service and catering

    Summer jobs waiting tables and working catering

Education
BSc Computer Science Minor in Mathematics Faculdade de Ciências, Universidade do Porto, Coding since age 13, always learning
Languages
  • Portuguese Native
  • English C2
  • Spanish C1
  • Japanese A1
Location
Porto, Portugal
Interests
Open source, Rubik's cubes, languages, motorcycles, the gym, cooking and bread
See also

Bill of materials

Filter by category

65 skills 50 software 15 hardware & CAD

Demos

Space builder

Title
Space Builder · Add Tool
Medium
Vue, Three.js
Year
Notes
Draw a rectangle on the floor and it fills with chairs. Sliders set the spacing, the aisles and the seat count.

Space Builder · Add Tool

Drawing No. 1 / 5

Place Area

Space Builder · Add Tool

Drawing No. 2 / 5

Edit Parameters

Space Builder · Add Tool

Drawing No. 3 / 5

Layout Styles

Space Builder · Add Tool

Drawing No. 4 / 5
Tag offset
φ=θ−α r = min ⁡ ( h 2 | cos ⁡ φ | , w 2 | sin ⁡ φ | ) offset=r+0.95

φ is the camera's angle relative to the area; r is the distance from center to the area's silhouette edge at that angle.

Tag offset
φ=θ−α r = min ⁡ ( h 2 | cos ⁡ φ | , w 2 | sin ⁡ φ | ) offset=r+0.95

φ is the camera's angle relative to the area; r is the distance from center to the area's silhouette edge at that angle.

Capacity Badge

Space Builder · Add Tool

Drawing No. 5 / 5

Marker editor

Title
Interactive Map Marker Editor
Medium
React, Leaflet, Redux
Year
Notes
A pin editor for venue maps. Shape, border, fill and decoration are separate parts, and a saved pin rebuilds itself from them.

Interactive Map Marker Editor

Drawing No. 1 / 6
Create marker Each marker gets the letteror number matching itsordering Create marker Each marker gets theletter or numbermatching its ordering

Marker Selector

Interactive Map Marker Editor

Drawing No. 2 / 6
React Dragging outside the render loop

The whole interactive map is a React app, so the editor started as a regular component. That held until I dragged a control point: every pointermove re-rendered a large subtree (solve constraints, preview SVG, update indicators, update sidebar) and the frame rate fell apart.

On drag, the handlers now mutate MarkerPart.controlPoints directly, then:

  1. Circles and crosses move by CSS translate through DOM refs.
  2. Each editor step is a class with its own imperativeReactComponent, a createRoot plus an .update() method, so only the part that changed is redrawn.
  3. Widgets that still need React (colours, text, icons) read an external reactiveState through useSyncExternalStore, so React re-renders only once that store is notified.
  • Circles: the handle you drag
  • Crosses: where the constraint put it
  • Sidebar: the parts a marker is made of

Marker Editor

Interactive Map Marker Editor

Drawing No. 3 / 6
Preview chrome Crosshair background
Failed Overlapping linear-gradients

H + V stripe layers stack into a grid, but each axis rounds its stops alone, so the columns drift.

Shipped SVG tile · repeat × 17

One tiny SVG draws a centered + from two short strokes in a viewBox="-1 -1 2 2". background-repeat tiles that cell so every cross stays coherent.

H stripes

Rows, no columns

V stripes

Columns, no rows

Overlap = grid

Crosses where lines meet

SVG cell

Short +, then tiled

Subpixel jitter Stable crosses Subpixel jitter Stable crosses

Preview Background

Interactive Map Marker Editor

Drawing No. 4 / 6
MarkerPart Composable stack
Shape Border Fill Decoration Composed
Serialization

Save writes typed data-* attrs (Point: / Number: / String:). Load rebuilds the parts.

In editor
  • PinMarkerShape
  • center → Point(0, -0.25)
  • IconMarkerDecoration
On the SVG
<path data-kind="PinMarkerShape"
  data-state-center="Point:0.000;-0.250" />
<g data-kind="IconMarkerDecoration" …>

Composable Parts

Interactive Map Marker Editor

Drawing No. 5 / 6
redux-undo History + batch
Past
  • markers′ · spaces′
  • markers″ · spaces″
Present
  • markers[] editor SVGs
  • spaces[] pin → markerId
Future
  • ∅ after edit
groupedUndo.batch
addMarker setSpaceMarker 1 undo

One shared group id, so Ctrl+Z or Cmd+Z undoes the whole batch.

Undoable Store

Interactive Map Marker Editor

Drawing No. 6 / 6

Drag and drop

Title
Space Builder · Drag and Drop
Medium
Vue, Three.js
Year
Notes
Drag a card off the catalogue and the object lands where it is dropped. Double-click one instead and the next click on the floor places it.

Space Builder · Drag & Drop

Drawing No. 1 / 2
Picture over the catalog, model over the floor

Four moments of one drag: the card's picture follows the pointer, the model takes over the moment the pointer crosses onto the floor, and letting go places it.

The pointer dragging the chair card out of the catalog
drag the object to the viewport
The chair picture riding the pointer over the catalog
over the catalog it is still a picture
The chair model under the pointer at the edge of the floor
over the floor it becomes the model
The chair placed on the floor with its selection highlight
let go and it is placed

Picture to Model

Space Builder · Drag & Drop

Drawing No. 2 / 2

Font picker

Title
Interactive Map Font Picker
Medium
React
Year
Notes
The list of faces comes from document.fonts, each face's Unicode subsets folded back into one entry. Nothing in it is hardcoded, so a font that loads later shows up on its own.

Interactive Map Font Picker

Drawing No. 1 / 4

Type a URL

Embed URL rust-lang.org

A page the visitor names in the Embed URL subform

Fetch the page

iframe x-frame-options: deny /api/font-proxy one origin, so the HTML can be read

rust-lang.org refuses to be framed, so a worker on this origin fetches the HTML

Read the stylesheets

<base href="https://rust-lang.org/"> <link href="/static/styles.css"> @import url("fonts.css") @font-face { font-family: "Alfa Slab One"; src: url(/api/font-proxy?url=https%3A%2F%2Frust-lang.org%2F…%2FAlfaSlabOne.woff2)

The browser follows every link and @import. It makes each src absolute and routes it through the proxy, since webfont loads are CORS-checked too

new FontFace().load()
document.fonts.add()

Show the new face

Poppins 400 Alfa Slab One 400

The dropdown re-reads document.fonts on loadingdone. The dot marks what the last source brought

A Google font skips the first two steps. fonts.googleapis.com/css2?family=Lobster returns the same kind of stylesheet, which joins the flow here

Font Extraction

Interactive Map Font Picker

Drawing No. 2 / 4
The font family field, as the request goes

Enter a name of a font that can be found on Google Fonts

The field's own border reports the fetch. A quarter turn orbits while the request is out, then the ring closes from wherever the orbit had got to, green for a font that exists and red for one that does not

1 turn 0 1s 750ms: go linear
cubic-bezier(0.75, 0.25, 0.2, 0.2) gives the orbit a push and lets it settle. Kept for every turn it stutters at each restart, so at 750ms the script swaps the timing function for linear at the point the curve has already reached. The seam goes, the first turn keeps its kick
conic-gradient, on the border box the field's own colour, on the padding box 2px of transparent border, which lets it through
One element, two backgrounds. The gradient is painted on the border box and the field's own colour covers the padding box over it, so the only part left showing is the border. Three custom properties move it: where the arc starts, how much of the turn is coloured, and the colour. @property types them as angles and a colour, which is what makes them animatable at all

Loading Indicator

Interactive Map Font Picker

Drawing No. 3 / 4

Font Size sets the column's own text size. Drag it and the row grows, so the slider walks out from under the pointer. FixedElement pins the row where the drag started. Both columns get the same drag, with the pointer held in one place

Held Controls

Interactive Map Font Picker

Drawing No. 4 / 4

Theme picker

Title
Theme Picker
Medium
Astro, CSS, View Transitions
Year
Notes
The control in the corner that picks one of this page's four themes. A pick grows the icon it was picked with from the corner until it covers the page, and the new page is cut out of the old along it. The first sheet plays it on a sample page with its own theme. The sheets behind it draw the wipe, its code, the tokens and who decides.

stage data-demo-theme= unset, so the OS scheme decides

Theme picker Sample pagewith its own theme Theme picker

Theme Picker

Drawing No. 1 / 5
The visible icon drives the animation by creating a starting point from it's placement and an end point with that same icon sized to cover the whole viewport
  • × 2.4
  • × 1.2
  • × 2
  • × 1.4
1First frame
The checked icon's <path>, mapped from its viewBox onto its box in the corner.
2Last frame
The same path, grown to a square the viewport's diagonal × the theme's factor wide.
3Between
clip-path from the one to the other, 500 ms ease-in, over a picture of the old page. Off under reduced motion.

The Wipe

Theme Picker

Drawing No. 2 / 5
The wipe's script in five steps, and the geometry of its last frame
  1. 1Start the transition with startViewTransition, the switch as its callback
    const viewTransition = document.startViewTransition(() => { /**/ });
  2. 2Once ready, measure the icon into rect and read its path
    viewTransition.ready.then(async () => {
      const rect = svg.getBoundingClientRect();
      const pathData = path.getPathData({ normalize: true });
  3. 3For the first frame, map the path from its viewBox onto the icon's box
      const startPathData = transformPath(pathData, viewBox,
        { x: rect.x, y: rect.y, width: rect.width, height: rect.height });
  4. 4For the last frame, map it onto a square 2 × size wide, centred on cx, cy
      const size = Math.hypot(window.innerWidth, window.innerHeight)
        * scaleFactor;
      const cx = rect.x + rect.width / 2;
      const cy = rect.y + rect.height / 2;
      const endPathData = transformPath(pathData, viewBox,
        { x: cx - size, y: cy - size, width: size * 2, height: size * 2 });
    Inside transformPath, for both frames
    const mapWidthAbsolute = (x: number) => ((x - fromViewBox.x) / fromViewBox.width) * toViewBox.width + toViewBox.x;
    Scales x from the viewBox width to the box's width and adds the box's left. y likewise.
    return [mapWidthAbsolute(width), mapHeightAbsolute(height)];
    Each command's values go through in x, y pairs.
    values: (transformers[type] ?? thrower(type))(values),
    Keeps each command's letter and maps its values into viewport pixels, the unit clip-path: path() takes.
  5. 5Clip the new page to the first frame with clip-path: path() and grow it to the last
      document.documentElement.animate(
        [{ clipPath: `path("${pathDataToString(startPathData)}")` },
         { clipPath: `path("${pathDataToString(endPathData)}")` }],
        { duration: 500, easing: 'ease-in',
          pseudoElement: '::view-transition-new(theme-transition)' });

The Wipe in Code

Theme Picker

Drawing No. 3 / 5
ThemePicker.astro · _colors.scss Four seeds per theme, three ramps mixed from them, and the names every sheet reads
seeds
  • canvas
  • ink
  • accent
  • blueprint
neutral
  1. 100
  2. 200
  3. 300
  4. 400
  5. 500
  6. 600
  7. 650
  8. 700
  9. 800
  10. 850
  11. 875
  12. 900
accent
  1. 100
  2. 200
  3. 300
  4. 400
  5. 500
  6. 600
  7. 650
  8. 700
  9. 800
  10. 850
  11. 875
  12. 900
blueprint
  1. 100
  2. 200
  3. 300
  4. 400
  5. 500
  6. 600
  7. 650
  8. 700
  9. 800
  10. 850
  11. 875
  12. 900
names
  • --surface-page
  • --ink
  • --line
  • --accent
  • --blueprint
Seeds
Four oklch colours on :root per theme. A dark theme's ink is light, so its ramps run the other way by themselves.
Ramps
A Sass loop mixes canvas into ink in eleven steps for neutral. accent and blueprint tint to 700, take the seed at 800 and shade to 900.
Names
What a sheet reads, never a step. A blueprint sheet like this one remaps them from its own paper.

Token Layers

Theme Picker

Drawing No. 4 / 5
ThemePicker.astro · Layout.astro The OS decides the theme until a pick, and from then on the stamp decides, before the first paint
  1. OS scheme painting @media (prefers-color-scheme: dark) { :root:not([data-theme]) } the OS only knows light and dark
  2. stamp painting :root[data-theme="dark"] written by the pick, kept as cv-theme
  3. radio painting :root:has(#theme-dark:checked) works without script
  1. localStorage
  2. <head> script
  3. data-theme on <html>
  4. first paint
On a reload, eight lines in the <head> stamp the kept pick before the first paint
Nothing picked
Only the OS rule matches, so the page follows the system and changes with it.
A pick
Writes the stamp, checks the radio, keeps cv-theme. color-scheme: only on <body> takes scrollbars and controls along.
Radios by id
An attribute inside :has() put every input on the page in :root's invalidation set; an id names one.

Default and Override

Theme Picker

Drawing No. 5 / 5

Object variants

Title
Space Builder · Object Variants
Medium
Vue, Sass
Year
Notes
One card covers a whole group of library objects. The carousel steps through one cell's finishes, the dropdowns move between cells, and a pair the catalogue never made falls back to the nearest one it did.

Space Builder · Object Variants

Drawing No. 1 / 3
CatalogObjectField.vue · CatalogObjectThumbnailOverlay.vue · HoverSelect.vue One card stands for every library object in its group
Arrows step the finishes One pipper style 4 objects, one card Seats pick a cell Size, other axis Next finish Pip per style 4 objects Seats: a cell Other axis
Group
Every library object under one catalog entry: 5 chairs, 4 banquet sets
Cells
pax and size cut the group, 3 seat counts by 2 sizes here; a cell's objects are its styles
Preview
An option previews on the card before you pick it

Variant Groups

Space Builder · Object Variants

Drawing No. 2 / 3
CatalogObjectFooter.vue · isAvailablePax What happens when the combination does not exist

Every seat count stays on the list. The 8 and 4 seat sets have no object at the size held, so they go grey. Grey is not disabled.

  1. Try the pair Same seat count, and the size already showing
  2. Else take the seat count The first object with that many seats, whatever its size
  3. Both readings move The size under the list changes to match what was found
Never disabled
A greyed option is still clickable. Grey says the other axis will move, not that the pick is refused
Sorted
Every value sorts in one descending run. Missing ones go grey and keep their place
Nothing selected
With no object picked yet, every value counts as available

Missing Variants

Space Builder · Object Variants

Drawing No. 3 / 3

Tagging tool

Title
Library Tagging Tool
Medium
CSS, JavaScript
Year
Notes
One input tags a base object and every variant under it. Variants that already disagree keep their own value, and an object leaves the page once nothing about it is missing. Pick another property and the values already stored for it come back.

Library Tagging Tool

Drawing No. 1 / 4
One "Shared value" input for the base object and every variant
Banquet Set · shared value
Ivory
keyup · focus · click · blur
  • 12 seats · base Ivory --caret: 5
  • 11 seats Ivory --caret: 5
  • 10 seats Ivory --caret: 5
  • 0 seats Ivory --caret: 5
Mirror
Every keystroke copies the value into each variant card's own input, scrolled to its end so the tail stays in view
Save
Finishing the field submits with update_styles: one row per object, titleized, and a cleared value destroys the rows
Warn
Variants that already disagree keep the input open but empty; the placeholder lists the overrides and the tick becomes a warning sign

Shared Value

Library Tagging Tool

Drawing No. 2 / 4
A caret for inputs that never have focus
selectionStart = 9 --caret: 9 left: min(var(--caret) * 1ch - var(--scroll), var(--input-width) - 1ch) + 0.5ch
  1. The shared input's keyup, focus, click, blur and input read selectionStart
  2. Each variant form gets --caret and its input's --input-width; the value is copied in and scrolled just far enough to keep that caret in view
  3. The form is monospace, so 1ch is one character and a ::before holding a literal | lands where the real caret would
  4. It only paints while the shared input :focus holds, blinks with opacity keyframes, and stands still under prefers-reduced-motion

Simulated Caret

Library Tagging Tool

Drawing No. 3 / 4

Fill the last gap, and the object leaves the list

Only objects still missing a value One row per object, on finishing the field Last gap filled: on its way out Only objects still missing a value One row per object, on finishing the field Last gap filled: on its way out

A save is find_or_initialize_by then save!; an emptied field destroys the row.

Completed Objects

Library Tagging Tool

Drawing No. 4 / 4

Paper stack

Title
Paper Stack
Medium
CSS, TypeScript
Year
Notes
Every demo on this page is a stack of drawing sheets, and the dog-ear turns them. These three sheets take the stack apart: the pile and its front page, the fold's three layers and the release that turns a page, and the row the stack becomes without JavaScript.
the clip, on the pin front page, --page-index 1 dog-ear, 2cm by 1cm pages to come, fanned about the pin

Paper Stack

Drawing No. 1 / 3

How a fold is drawn

  1. the page, cut on the crease
  2. the shade, on the clip wrapper
  3. the flap, the back mirrored over
T, the pointer C, the corner the crease, halfway commit point, ¾ in

The Fold

Paper Stack

Drawing No. 2 / 3

The stack without JavaScript

snaps one sheet at a time next sheet, out of view arrow, half over the edge a dot per sheet, the current one lit the pile, two shadows

One rule carries all of it: @media (scripting: none).

Without JavaScript

Paper Stack

Drawing No. 3 / 3

GNU social event bus

Title
GNU social · Event Dispatch
Medium
PHP, Symfony
Year
Notes
GNU social is a federated social network server. Its plugins never call each other: a plugin listens for an event by naming a method after it, and the first one to answer stop has the job. Switch ImageEncoder off and no other plugin claims the JPEG, so the template draws a plain link. ImageEncoder shows a GIF too, then asks for its thumbnail with a second event, FileResizerAvailable. That one has two takers at the same priority. VideoEncoder asked for image/gif by name, so it goes first and keeps the GIF moving. Switch it off and ImageEncoder makes a still.

Each listener is [Module, 'onViewAttachment'], in the order the modules were found ImageEncoder's view asks for the GIF's thumbnail, and getOrCreate raises this. Each listener is [Module, 'onFileResizerAvailable']

  1. AudioEncoder prio 0
    next
  2. ImageEncoder prio 0
    stop
  3. VideoEncoder prio 0
    skipped
Not loaded
{% for block in handle_event('ViewAttachment', …) %}
    {{ block | raw }}
{% endfor %}
{% if not handled %}
    <a href="{{ attachment.getShowUrl(note) }}">{{ title }}</a>
{% endif %}
no block came back, so the template writes its own link a block came back, so the template's link is not written
then getOrCreate tries the resizers in this order array_merge($event_map['image/gif'], $event_map['image'])
  1. 'image/gif' VideoEncoder::resizeVideoPath true
  2. 'image' ImageEncoder::resizeImagePath not called
Every module handler registers at the same priority, so GNU social orders these by how specific the mimetype is.
$res$event_map
'image/gif' => [VideoEncoder]'image' => [ImageEncoder] <img class="u-photo"> ImageEncoder <video controls> VideoEncoder <audio controls> AudioEncoder harbour.jpg attachment.html.twig

GNU social · Event Dispatch

Drawing No. 1 / 4

One handler returns Event::stop, and propagation ends there AudioEncoder hears the JPEG first and answers next. ImageEncoder claims it and appends its view to $res. VideoEncoder never hears the event.

enum EventResult
case next chain goes on
Not this handler's job, or its part is done and others may add theirs. Every encoder answers next to a mimetype that is not its own
case stop chain halts
Claimed the job. Nothing after it hears the event, and $res holds what it appended
case unhandled chain goes on
Declared in the enum, returned by no handler in the tree: a handler with nothing to add says next

EventResult

GNU social · Event Dispatch

Drawing No. 2 / 4

No module registers a listener Each directory's main class in components/*/ and plugins/*/ is a module. Its public on<EventName> methods listen for that event.

components/*/
  • Attachment
  • Avatar
  • Conversation
  • Feed
  • Posting
  • Tag
  • +13
plugins/*/
  • ActivityPub
  • AudioEncoder
  • Embed
  • ImageEncoder
  • VideoEncoder
  • +29
plugins/ImageEncoder/ImageEncoder.php becomes listener table
public static function version (): string not a listener
public static function shouldHandle (string $mimetype): bool not a listener
public function onFileMetaAvailable (array &$event_map, string $mimetype): EventResult becomes FileMetaAvailable [ImageEncoder, 'onFileMetaAvailable'] prio 0
public function onFileSanitizerAvailable (array &$event_map, string $mimetype): EventResult becomes FileSanitizerAvailable [ImageEncoder, 'onFileSanitizerAvailable'] prio 0
public function onFileResizerAvailable (array &$event_map, string $mimetype): EventResult becomes FileResizerAvailable [ImageEncoder, 'onFileResizerAvailable'] prio 0
public function onViewAttachment (array $vars, array &$res): EventResult becomes ViewAttachment [ImageEncoder, 'onViewAttachment'] prio 0
public function resizeImagePath (string $source, …): bool not a listener
public function onPluginVersion (array &$versions): EventResult becomes PluginVersion [ImageEncoder, 'onPluginVersion'] prio 0

Found by reflection in a compiler pass, once, when the container is built. The table is written into the compiled container, so a request pays nothing for the wiring.

Module Discovery

GNU social · Event Dispatch

Drawing No. 3 / 4

The wrong return type fails the build, not a request The same compiler pass that finds a handler reads its declared return type, and anything but EventResult throws BugFoundException before the container is written.

plugins/ImageEncoder/ImageEncoder.php:183
public function onViewAttachment(
    array $vars,
    array &$res,
): bool {
    if (!self::shouldHandle($vars['attachment']->getMimetype())) {
        return Event::next;
    }
    …
    return Event::stop;
}
shell, the container build
$ bin/console cache:clear

 // Clearing the cache for the dev environment with debug true

In ModuleManager.php line 93:

  [App\Util\Exception\BugFoundException]

$ tail -n 1 var/log/dev.log
app.CRITICAL: Return type of Plugin\ImageEncoder\ImageEncoder::onViewAttachment is not the required EventResult in src/Core/ModuleManager.php:93

A handler that declares no return type gets past the pass. The dispatch catches it the first time it returns nothing (src/Core/Event.php:93): Events must return an \EventResult, which {file} for {event} does not.

Build-Time Guard

GNU social · Event Dispatch

Drawing No. 4 / 4

Fediverse playground

Title
Fediverse Playground
Medium
TypeScript, Docker
Year
Notes
A command-line tool that runs a small federated network on one machine, so you can test how fediverse servers talk to each other before a release. Each server gets its own hostname, database and branch. One YAML file in, a Docker Compose stack out.

Fediverse Playground runs a small federated network on one machine, to test how fediverse servers talk to each other. Switch servers on and off to redraw the docker-compose.yml it writes.

playground.config.yaml defaults.local_tld: fediverse recipes: social-v3, social-v2, mastodon planned: lemmy, friendica, gnu-social-v1

instances

13 services · 28 depends_on, all defined

3 servers behind nginx, on db, redis, search and mediagnusocial-alice uses db, redis and media. gnusocial-bob uses db, redis and media. mastodon-carol uses db, redis, search and media.completedcompletedcompleteddbpostgres:alpineredisredis:alpinemediadarthsim/imgproxymariadbnot emittedsearchelasticsearch:7.17.4nginxnginx:alpine8080 8443gnusocial-alicesocial-v3alice.localhostgnusocial-alice-installone-shotrestart: nognusocial-bobsocial-v3bob.localhostgnusocial-bob-installone-shotrestart: nognusocial-v2not emittedgnusocial-v2-installnot emittedmastodon-carolmastodoncarol.localhost3 processesmastodon-carol-installone-shotrestart: no3 servers behind nginx, on db, redis, search and mediagnusocial-alice uses db, redis and media. gnusocial-bob uses db, redis and media. mastodon-carol uses db, redis, search and media.completedcompletedcompleteddbpostgres:alpineredisredis:alpinemediadarthsim/imgproxymariadbnot emittedsearchelasticsearch:7.17.4nginxnginx:alpine8080 8443gnusocial-alicesocial-v3alice.localhostgnusocial-alice-installone-shotrestart: nognusocial-bobsocial-v3bob.localhostgnusocial-bob-installone-shotrestart: nognusocial-v2not emittedgnusocial-v2-installnot emittedmastodon-carolmastodoncarol.localhost3 processesmastodon-carol-installone-shotrestart: no
docker-compose.yml 285 lines, 96 shown
# This file was generated by Fediverse Playground (GPL3+)
version: "3"
name: fediverse-playground
services:
  nginx:
    image: nginx:alpine
    ports:
      - 8080:80
      - 8443:443
    depends_on:
      - gnusocial-alice
      - gnusocial-bob
      - mastodon-carol
      - mastodon-carol-streaming
  db:
    image: postgres:alpine
  redis:
    image: redis:alpine
  media:
    image: darthsim/imgproxy
  gnusocial-alice-install:
    build: instances/gnusocial-alice/docker/php
    depends_on:
      db:
        condition: service_healthy
  gnusocial-alice:
    build: instances/gnusocial-alice/docker/php
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
      media:
        condition: service_started
      gnusocial-alice-install:
        condition: service_completed_successfully
  gnusocial-bob-install:
    build: instances/gnusocial-bob/docker/php
    depends_on:
      db:
        condition: service_healthy
  gnusocial-bob:
    build: instances/gnusocial-bob/docker/php
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
      media:
        condition: service_started
      gnusocial-bob-install:
        condition: service_completed_successfully
  search:
    image: elasticsearch:7.17.4
  mastodon-carol-install:
    build: instances/mastodon-carol
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
  mastodon-carol:
    build: instances/mastodon-carol
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
      search:
        condition: service_started
      mastodon-carol-install:
        condition: service_completed_successfully
  mastodon-carol-streaming:
    build:
      context: instances/mastodon-carol
      dockerfile: streaming/Dockerfile
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
      mastodon-carol-install:
        condition: service_completed_successfully
  mastodon-carol-sidekiq:
    build: instances/mastodon-carol
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
      search:
        condition: service_started
      media:
        condition: service_started
      mastodon-carol-install:
        condition: service_completed_successfullyLeft out here: volumes, environment, restart, tty, command and healthcheck.

Fediverse Playground

Drawing No. 1 / 4
One bare mirror per software, one worktree per instance An example: the shipped config puts bob on main. Here he asks for v3, which alice's worktree already holds, so the branch is renamed after him.
mirrors/social-v3 git clone --bare · fetch --all on every sync
  • instances/gnusocial-alice v3
  • instances/gnusocial-bob v3 held by gnusocial-alice v3-gnusocial-bob
mirrors/social-v2 git clone --bare · fetch --all on every sync
  • instances/gnusocial-v2 nightly
mirrors/mastodon git clone --bare · fetch --all on every sync
  • instances/mastodon-carol main
gnusocial-bob: the first rung that matches runs
  1. local branch already exists git worktree add instances/gnusocial-bob v3-gnusocial-bob
  2. branch off the local head git worktree add -b v3-gnusocial-bob instances/gnusocial-bob v3
  3. branch off origin git worktree add -b v3-gnusocial-bob instances/gnusocial-bob origin/v3
  4. start a new branch git worktree add -b v3-gnusocial-bob instances/gnusocial-bob

A Branch for Every Server

Fediverse Playground

Drawing No. 2 / 4
A one-shot installer brings each server up gnusocial-alice's containers, in the order the compose file starts them
  1. db: pg_isready every 3s
  2. db to gnusocial-alice-install: service_healthy
  3. gnusocial-alice-install: composer install
  4. gnusocial-alice-install to db: doctrine:schema:create
  5. gnusocial-alice-install: app:populate_initial_values
  6. gnusocial-alice-install to db: seed-v3-admin.php
  7. gnusocial-alice-install: exit 0
  8. gnusocial-alice-install to gnusocial-alice: service_completed_successfully
  9. gnusocial-alice: entrypoint.sh
  10. gnusocial-alice to nginx: nginx.depends_on
  11. nginx: server_name alice.localhost alice.fediverse
One shot
The installer has restart: no. Its lane stops at exit 0, and only then does the app start.
Run again
If the language table has rows, the script only seeds the admin again and exits 0, so up is safe to repeat.
Its name
CONFIG_DOMAIN=alice.localhost tells the server who it is on the network. Its admin is [email protected].
v2
The v2 server has the same pair, gated on mariadb instead of Postgres.

Bringing a Server Up

Fediverse Playground

Drawing No. 3 / 4
Every server answers to two names Its hostname, and a .fediverse twin from defaults.local_tld, each written everywhere it has to resolve or be served
name hosts.snippetsnippetapply-hosts → /etc/hostshosts.nix.nixNixOS networking.hostsserver_namenginxthe server's nginx blocksubjectAltNameSANthe server's certificate
gnusocial-alice social-v3
alice.localhost yesyesyes
alice.fediverse yesyesyesyes
gnusocial-bob social-v3
bob.localhost yesyesyes
bob.fediverse yesyesyesyes
gnusocial-v2 social-v2
v2.localhost yesyesyes
v2.fediverse yesyesyesyes
mastodon-carol mastodon
carol.localhost yesyesyes
carol.fediverse yesyesyesyes

snippet hosts.snippet.nix hosts.nixnginx server_nameSAN subjectAltName

Why two
*.localhost already resolves to 127.0.0.1, so hosts.nix leaves it out. *.fediverse needs an entry.
apply-hosts
Merges the marked block from files/hosts.snippet into /etc/hosts.
NixOS
The flake exports nixosModules.hosts, which reads files/hosts.nix.
Dev shell
Entering it checks every *.fediverse name with getent and prints one hint if one does not resolve.

Names on the Network

Fediverse Playground

Drawing No. 4 / 4

Synthetic properties

Title
Synthetic Properties
Medium
Rails, MariaDB
Year
Notes
A library object stores its size once, in centimetres. On each save, database triggers wrote that size out in centimetres, metres, inches and feet, so a search for 5ft3in or 1.6m found the same table. The triggers lived five weeks on a branch before Ruby callbacks replaced them.
One table's size, split, converted and searched

The table's size is stored as 160x80x74, in centimetres. When the row is saved, triggers split it on x, write each piece in centimetres, metres, inches and feet, and store every form in the text that search reads.

The walkthrough types in these fields. They don't take your own typing.

  1. 1 Stored library_objects
    split on x, one row per dimension
    1. 160
    2. 80
    3. 74
  2. 2 Converted library_object_synthetic_properties
    • cm 160cm by 80cm by 74cm
    • m 1.6m by 0.8m by 0.74m
    • in" 63" by 31" by 29"
    • in 63in by 31in by 29in
    • ft'in" 5'3" by 2'7" by 2'5"
    • ft in 5ft3in by 2ft7in by 2ft5in
    • Pax 8
    • Seats 8

Synthetic Properties

Drawing No. 1 / 4
Each dimension in six units Each row of split_sizes goes through six SELECTs, one per unit, and GROUP_CONCAT joins a unit's pieces with ' by '. Centimetres and metres keep two significant figures. Inches round to the whole inch.
Unit 16080182
Size (centimeters) ROUND(cm, 1 - FLOOR(LOG10(cm))) 160cm 80cm 180cm
Size (meters) ROUND(cm / 100, 3 - FLOOR(LOG10(cm))) 1.6m 0.8m 1.8m
Size (inches) ROUND(cm / 2.54) 63" 31" 72"
Size (inches) ROUND(cm / 2.54) 63in 31in 72in
Size (feet-inches) FLOOR(in / 12) + carry, ROUND(in MOD 12) if not 0 5'3" 2'7" 6'
Size (feet-inches) FLOOR(in / 12) + carry, ROUND(in MOD 12) if not 0 5ft3in 2ft7in 6ft

182cm is 71.65in, or 5ft and 11.65in. The 11.65in rounds to 12, so it carries and prints as 6ft, not 5ft12in.

The shipped SQL did not round, so 182.5 printed as 182.5cm and 1.825m. It added a foot from six leftover inches, so 80cm read 3ft7in, and it wrote whole feet as 6ft0in. The rules above fix all three. When rounding changes a size, the exact centimetres and metres are kept too, so a search for 182cm still finds it.

Unit Conversions

Synthetic Properties

Drawing No. 2 / 4
The same SQL in the backfill and three triggers The backfill and the insert and update triggers on library_objects call one Ruby method, each with its own where. The delete trigger only deletes the rows. The hairtrigger gem writes the triggers into a migration.
app/models/library_object.rb
def self.size_synthetic_property_select_sql(where = "1=1")
  <<~SQL.chomp
    WITH RECURSIVE split_sizes AS (…)  -- one row per dimension
    SELECT id AS library_object_id, 'Size (centimeters)' AS name,
      GROUP_CONCAT(CONCAT(ROUND(size, 1 - FLOOR(LOG10(size))), 'cm') SEPARATOR ' by ') AS value
    FROM split_sizes WHERE #{where} AND size <> '' GROUP BY id
    UNION ALL  -- five more, and the exact cm and m where they round
  SQL
end
  1. Backfill rake data_migrations:initialize_…, once where = "1=1"
    INSERT IGNORE INTO library_object_synthetic_properties  (library_object_id, name, value)#{size_synthetic_property_select_sql}UNION ALL#{pax_synthetic_property_select_sql};
  2. trigger.after(:insert) where = "id = NEW.id"
    INSERT INTO library_object_synthetic_properties  (library_object_id, name, value)#{size_synthetic_property_select_sql("id = NEW.id")}UNION ALL#{pax_synthetic_property_select_sql("id = NEW.id")};
  3. trigger.after(:update).of(:size) where = "id = NEW.id"
    UPDATE library_object_synthetic_properties AS lopsJOIN (  #{size_synthetic_property_select_sql("id = NEW.id")}) AS tempON lops.library_object_id = temp.library_object_id  AND lops.name = temp.nameSET lops.value = temp.valueWHERE lops.library_object_id = NEW.id;
  4. trigger.after(:delete)
    DELETE FROM library_object_synthetic_propertiesWHERE library_object_synthetic_properties.library_object_id = OLD.id;

Database Triggers

Synthetic Properties

Drawing No. 3 / 4
An object's rows joined into one string for FULLTEXT search Triggers on library_object_synthetic_properties rebuild the string whenever one of the object's rows changes.
library_object_synthetic_properties
objectnamevalue
1024 Size (centimeters) 160cm by 80cm by 74cm
1024 Size (meters) 1.6m by 0.8m by 0.74m
1024 Size (inches) 63" by 31" by 29"
1024 Size (inches) 63in by 31in by 29in
1024 Size (feet-inches) 5'3" by 2'7" by 2'5"
1024 Size (feet-inches) 5ft3in by 2ft7in by 2ft5in
1024 Pax 8
1024 Seats 8
app/models/library_object_synthetic_property.rb · AFTER INSERT and UPDATE, each row
INSERT INTO library_object_properties_concatenated
  (`library_object_id`, `values`)
SELECT
    lops.library_object_id,
    GROUP_CONCAT(CONCAT(lops.`value`, ' ', lops.`name`)
      SEPARATOR ' ')
  FROM library_object_synthetic_properties lops
  WHERE lops.library_object_id = NEW.library_object_id
    AND `lops`.`value` IS NOT NULL
  GROUP BY lops.library_object_id
ON DUPLICATE KEY UPDATE `values` = VALUES(`values`);
library_object_properties_concatenated.values · FULLTEXT
160cm by 80cm by 74cm Size (centimeters) 1.6m by 0.8m by 0.74m Size (meters) 63" by 31" by 29" Size (inches) 63in by 31in by 29in Size (inches) 5'3" by 2'7" by 2'5" Size (feet-inches) 5ft3in by 2ft7in by 2ft5in Size (feet-inches) 8 Pax 8 Seats

Searchable Text

Synthetic Properties

Drawing No. 4 / 4

Schema driver

Title
schemaDef → Doctrine Metadata
Medium
PHP, Symfony, Doctrine
Year
Notes
GNU social is a federated social network server, written in PHP. Each of its tables is one PHP array on the entity class, and a Doctrine driver turns that array into the ORM mapping, the database schema and the types PHPStan checks.

GNU social is a federated social network server. Each table is a PHP array on its entity class, and this driver turns that array into Doctrine's mapping, printed here as the SQL it creates.

Actor::schemaDef() src/Entity/Actor.php
public static function schemaDef(): array{return ['name' => 'actor','description' => 'local and remote users, groups and bots are actors, for instance','fields' => ['id' => ['type' => 'serial', 'not null' => true, 'description' => 'unique identifier'],'nickname' => ['type' => 'varchar', 'length' => 64, 'not null' => true, 'description' => 'nickname or username'],'fullname' => ['type' => 'text', 'description' => 'display name', 'default' => null],'roles' => ['type' => 'int', 'not null' => true, 'description' => 'Bitmap of permissions this actor has'],'type' => ['type' => 'int', 'not null' => true, 'description' => 'The type of actor (person, group, bot, etc)'],'homepage' => ['type' => 'text', 'description' => 'identifying URL', 'default' => null],'bio' => ['type' => 'text', 'description' => 'descriptive biography', 'default' => null],'location' => ['type' => 'text', 'description' => 'physical location', 'default' => null],'lat' => ['type' => 'numeric', 'precision' => 10, 'scale' => 7, 'description' => 'latitude', 'default' => null],'lon' => ['type' => 'numeric', 'precision' => 10, 'scale' => 7, 'description' => 'longitude', 'default' => null],'location_id' => ['type' => 'int', 'description' => 'location id if possible', 'default' => null],'location_service' => ['type' => 'int', 'description' => 'service used to obtain location id', 'default' => null],'is_local' => ['type' => 'bool', 'not null' => true, 'description' => 'Does this actor have a LocalUser associated'],'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'],'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'],],'primary key' => ['id'],'indexes' => ['actor_nickname_idx' => ['nickname'],],'fulltext indexes' => ['actor_fulltext_idx' => ['nickname', 'fullname', 'location', 'bio', 'homepage'],],];}
MySQL SchemaDefDriver, printed as DBAL would
CREATE TABLE actor (id INT AUTO_INCREMENT NOT NULL COMMENT 'unique identifier',nickname VARCHAR(64) NOT NULL COMMENT 'nickname or username',fullname LONGTEXT DEFAULT NULL COMMENT 'display name',roles INT NOT NULL COMMENT 'Bitmap of permissions this actor has',type INT NOT NULL COMMENT 'The type of actor (person, group, bot, etc)',homepage LONGTEXT DEFAULT NULL COMMENT 'identifying URL',bio LONGTEXT DEFAULT NULL COMMENT 'descriptive biography',location LONGTEXT DEFAULT NULL COMMENT 'physical location',lat NUMERIC(10, 7) DEFAULT NULL COMMENT 'latitude',lon NUMERIC(10, 7) DEFAULT NULL COMMENT 'longitude',location_id INT DEFAULT NULL COMMENT 'location id if possible',location_service INT DEFAULT NULL COMMENT 'service used to obtain location id',is_local TINYINT(1) NOT NULL COMMENT 'Does this actor have a LocalUser associated',created DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT 'date this record was created',modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT 'date this record was modified',INDEX actor_nickname_idx (nickname),-- actor_fulltext_idx: not read by SchemaDefDriverPRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDBCOMMENT = 'local and remote users, groups and bots are actors, for instance';
  • Rule Hover, tap or focus a line in the PHP or the SQL to see what the driver did to it

schemaDef → Doctrine Metadata

Drawing No. 1 / 4
schemaDef Doctrine MySQL e.g.
'varchar' string VARCHAR(64) actor.nickname
'char' string CHAR(88) rememberme_token.series
'serial' integer INT AUTO_INCREMENT actor.id
'int' integer INT actor.roles
'tiny' + 'int' smallint SMALLINT local_user.subscription_policy
'big' + 'int' bigint BIGINT foreign_user.id
'bool' boolean TINYINT(1) actor.is_local
'numeric' decimal NUMERIC(10, 7) actor.lat
'text' text LONGTEXT actor.bio
'datetime' datetime DATETIME actor.created
'timestamp' datetime DATETIME actor.modified
'phone_number' phone_number bundle type local_user.phone_number

Mapped ahead of need, unused by any v2 schema: date, time, datetimez, object, array, simplearray, json_array, float, guid, blob

Size
Glued onto the name before the lookup: 'tiny' on an int looks up tinyint, which has no portable form, so smallint
Null
'not null' => true is inverted into 'nullable' => false; a field that says nothing is nullable
Width
char is the same string type as varchar, with 'fixed' => true
Serial
An integer plus GENERATOR_TYPE_AUTO on the class
Docs
Each 'description' becomes a column comment: the documentation lands in the database
Keys
['idx' => [cols]] is reshaped into [['name' => 'idx', 'columns' => [cols]]]

Type Vocabulary

schemaDef → Doctrine Metadata

Drawing No. 2 / 4
  • src/Entity/ App\Entity 17 · core
  • components/*/Entity/ Component\<Name>\Entity 29 · 12 components
  • plugins/*/Entity/ Plugin\<Name>\Entity 28 · 16 plugins

addDriver(app.schemadef_driver, $namespace)

doctrine.orm.default_metadata_driver One driver chain, one SchemaDefDriver, 74 entities as of October 2022 isTransient($class) = !method_exists($class, 'schemaDef')
src/Core/ModuleManager.php · process()
foreach ($module_paths as $path) {// … $type, $module and $dir from the pathif (!\is_null($container) && file_exists($entity_dir = $dir . '/Entity') && is_dir($entity_dir)) {$entity_paths[] = $entity_dir;$container->findDefinition('doctrine.orm.default_metadata_driver')->addMethodCall('addDriver',[new Reference('app.schemadef_driver'), "{$type}\\{$module}\\Entity"],);}}

plugins/Favourite/Entity/NoteFavourite.php is a whole plugin table: drop it in and note_favourite exists. Its 'foreign key' => true is still read by nobody, since the driver's association branch sits behind if (false && …), so note_id maps as a plain INT.

Plugin Entities

schemaDef → Doctrine Metadata

Drawing No. 3 / 4
components/Link/Entity/NoteToLink.php
$note = DB::find('note', ['id' => $args['note_id']]); // PHPStan: App\Entity\Note|null
  1. 1 Boot flag PHPSTAN_BOOT_KERNEL=1vendor/bin/phpstan analyse The analyser runs with the real kernel behind it, not a stub phpstan.sh
  2. 2 Real kernel $kernel->boot();return $kernel; GNUsocialProvider requires it, then starts every module as a request would config/phpstan-bootstrap.php
  3. 3 Table map DB::initTableMap();'note' => 'App\Entity\Note' Built from getAllMetadata(), so from the same schemaDefs the driver reads DB.php
  4. 4 Return type new UnionType([ Note, new NullType(),]) For find, getReference, getPartialReference and getRepository with a literal first argument ClassFromTableName….php

Without the flag the extension steps aside and PHPStan types the call the ordinary way, so a plain run still works; with it, whatever is called on $note next is checked against Note like any other typed value.

Static Analysis

schemaDef → Doctrine Metadata

Drawing No. 4 / 4

web-ts-mode

Title
web-ts-mode
Medium
Emacs Lisp, tree-sitter
Year
Notes
An Emacs major mode for web templates such as Astro and Vue, where one file mixes markup, TypeScript and styles. Each language gets its own tree-sitter parser over its part of the file, and highlighting and indenting work across the whole file.
Notes
  1. web-ts-mode is an Emacs major mode for files that mix languages. This Astro component from the CV has TypeScript in its frontmatter, markup with expressions in it, and a SCSS style block.
  2. The astro parser reads the whole file, markup included. The TypeScript and the SCSS each get a parser of their own that sees only its range. Each dashed box is one range, drawn inside the range that holds it.
  3. The site's build colours the code with a small lexer that follows the mode's faces. The ranges are the ones Emacs 31.1 hands each parser for this file.
  1. astro, lines 1 to 34
  2. typescript, lines 2 to 19
  3. html, lines 22 to 34
  4. tsx, line 22
  5. tsx, line 24
  6. tsx, line 25
  7. scss, lines 30 to 33
---
/**
* The sheet number in the desk's left margin: a stencil numeral with that sheet's own title
* under it, riding along while the sheet scrolls past. It is decoration, so assistive tech
* never sees it; the sheet carries the same words as its heading.
*
* It is a child of #main rather than of the sheet, because the margin it stands in belongs to
* the desk and not to the paper. Absolutely positioned in the left margin column of the grid
* row its sheet sits in, it takes that row as its containing block, which is what gives the
* numeral the sheet's own height to stick along. The row is the number: there is one folio
* per sheet and they run in page order. Both ends of the row are named, since a grid line an
* absolutely positioned child leaves at auto resolves to the grid's own padding edge.
*/
type Props = {
number: number;
label: string;
};
const { number, label } = Astro.props;
---
<div class="folio-rail" style={`--folio-row: ${number} / ${number + 1}`} aria-hidden="true">
<p class="folio">
<span class="folio-number stencil">{String(number).padStart(2, '0')}</span>
<span class="folio-label typewriter">{label}</span>
</p>
</div>
<style lang="scss">
.folio-label {
font-size: 0.75rem;
letter-spacing: 0.06em;
}
</style>

Point at or tab to the code to see which parser owns itastro: the host parser, over the whole buffertypescript: frontmatter (frontmatter_js_block), :local ttsx: attribute_interpolation (attribute_js_expr), :local ttsx: element (html_interpolation), :offset '(1 . -1), :local ttsx: element (html_interpolation), :offset '(1 . -1), :local tscss: style_element (raw_text), picked by lang="scss", :local t

web-ts-mode

Drawing No. 1 / 4

One interpolation, one TSX program

PostIt.astro, line 3
buffer Astro.props.title && <h3>{Astro.props.title}</h3>} astro
html_interpolation
permissible_text element
tsx
brace left out one range, :offset '(1 . -1) brace left out
skip
nested, left out
Whole program › binary_expression member_expression && jsx_element
Text only "Astro.props.title && " ERROR
Range rules
frontmatter_js_block typescript
attribute_js_expr tsx unless inside an interpolation
script_element raw_text tsx
element > html_interpolation tsx top level only, :offset '(1 . -1)
style_element raw_text scss | css by lang="scss"
Each rule is :local t, so every capture gets a parser of its own.

Parser Ranges

web-ts-mode

Drawing No. 2 / 4

Each level moves the hue toward green

section {
.aside:not(:has(.note-card > *)) .note-fold {
display: none;
}
&:not(:has(.note-card > *)) {
.content {
grid-column: 1 / -1;
}
}
}
Depth, and the share of the way to hue 0.33
  1. 0 base face
  2. 1 0.22
  3. 2 0.44
  4. 3 0.66
  5. 4 0.88
  6. 5 1.00 end hue
.aside:not( 0 :has( 1 .note-card 2 > 1 * 3 ) 1 ) 0 .note-fold 4
Notes
  1. In a > b the right side starts one level past the deepest on its left.
  2. The arguments of :not() and :has() sit one level deeper than the pseudo-class.
  3. The branches of a, b share a depth. A nested rule starts again at 0.

Selector Depth Hue

web-ts-mode

Drawing No. 3 / 4

One command says what is installed and what is not

Notes
  1. Each language needs a tree-sitter grammar, and completion and error checks need a language server on PATH. M-x web-ts-doctor lists both.
  2. A missing grammar shows up as a line here. When a file opens, the mode leaves that language unhighlighted and carries on.
  3. This is the report from the machine that builds this CV. Every grammar is installed, and one server, astro-ls.
web-ts-mode doctor================= Emacs: 31.1System type: gnu/linux Grammars--------  astro: ok  css: ok  tsx: ok  typescript: ok  javascript: ok  html: ok  scss: ok  xml: ok  jsdoc: ok  vue: ok LSP---  client: eglot  auto-enable: t Language servers on PATH------------------------  astro-ls: found (node_modules/.bin/astro-ls --stdio)  vue: not found (tried vue-language-server, vls)  html: not found (tried vscode-html-language-server, html-language-server, vscode-html-languageserver, html-languageserver)  xml: not found (tried lemminx, xml-language-server)  typescript: not found (tried typescript-language-server)  css: not found (tried vscode-css-language-server, css-languageserver, vscode-css-languageserver) CLI---  bin/web-ts grammars  — install pinned tree-sitter grammars  bin/web-ts doctor    — this report (batch)  bin/web-ts test      — ERT suite  bin/web-ts compile   — byte-compile package .el files  bin/web-ts bench     — soft perf timings

Doctor Report

web-ts-mode

Drawing No. 4 / 4

Open Source

Merged
23
Open
25
Closed
27

Checked

Maintainer

GNU social v3

GNUsocial/gnu-social
Commits
~1200
Period
2021–2022

Led the GNU social v3 rewrite from 2021 to 2022 with ~1200 commits, funded by NLnet through the NGI0 Discovery programme. Ported the federated microblogging stack to Symfony 5, with an event driven and modular architecture, rewrote controllers and the posting form layer, expanded PHPUnit coverage, integrated PHPStan and dependency-hygiene tooling

GSoC student

Blender GSoC 2019

blender/blender
Period
2019

Proposed replacing Blender's Python OBJ, STL, and PLY importers/exporters with a shared C/C++ IO framework using memory-mapped files and optional parallelization. A prototype patch made OBJ and STL import and export about 3× faster, and the proposal was accepted into the GSoC programme.

Merged 23

Reworked Astro's compiler_rs's scope_css pass so combinators inside :global() hoist correctly, dropping the broken ParserOptions::css_modules path

· +786 −215 · 6 files

Fixed JSX-in-object-literal transforms in the Astro Rust compiler so object literals can contain JSX expressions (astro#17198).

· +86 −0 · 5 files

Added a close-on-hide prop to BPopover so popovers dismiss when clipping middleware hides them from view.

· +92 −7 · 4 files

Added React support for transitionrun, transitionstart, and transitioncancel events, with matching test coverage.

· +78 −1 · 6 files

Fixed chained Redux observers so each run sees the latest store state after upstream observers dispatch updates.

· +52 −3 · 2 files

Forwarded Leaflet's feature property generic through @types/leaflet so callers can type GeoJSON features precisely.

· +28 −13 · 3 files

Enabled strictNullChecks on the Leaflet typings and fixed the resulting type errors.

· +22 −17 · 3 files

Added an inf-ruby variable to wrap the console command, making it easy to launch Ruby inside Docker.

· +24 −5 · 2 files

Brought @types/leaflet up to Leaflet 1.9.1.

· +17 −2 · 1 file

Added an n format specifier for std::tuple and std::pair in {fmt}, following the tuple-formatting design from #4090.

· +11 −1 · 2 files

Added a width prop to BOffcanvas so sidebar sizing matches bootstrap-vue migration expectations.

· +7 −0 · 2 files

Aliased BFormInput as BInput for bootstrap-vue compatibility.

· +2 −0 · 2 files

Named the Debian package that carries the BlueZ headers in the PyBluez build requirements, so the README says what to install rather than what is needed.

· +1 −1 · 1 file

Filled in missing Leaflet methods in the DefinitelyTyped declarations for v1.8.

Added entries and from_entries helpers to functional-php, mirroring JavaScript's Object.entries / Object.fromEntries.

Added an i3blocks memory option to hide the percentage label while keeping the usage bar.

Bumped Android Gradle tooling in masked-view to unblock react-native-gesture-handler builds.

Bumped Android Gradle tooling in Reanimated to unblock react-native-gesture-handler builds.

Fix a spurious Assimp warning tracked in assimp#3222.

Extended Symfony's Translator so trans() accepts ICU-formatted messages directly (symfony#37228).

Added ary, a helper that trims excess arguments before invoking a callable.

Repaired functional-php's PHPUnit suite after upstream dependency and PHP version drift.

Bumped Android Gradle tooling in react-native-safe-area-context to unblock a react-native-gesture-handler build.

Open 25

Kept the microphone open while a push-to-talk dictation transcribes in Voxtype. A press in that window is queued and recorded on the open capture, so the next sentence is no longer lost.

· +118 −1 · 1 file

Added hotkey.cancel_on_other_key for a modifier used as the hotkey. Pressing any other key while it is held cancels the recording, so Right Ctrl+C no longer dictates.

· +87 −0 · 4 files

Added hotkey.double_tap_latch. A double tap keeps the recording running after the key is released, until the next press, and holding the key works as before. A small TapLatch state machine in the evdev listener does the timing.

· +178 −1 · 4 files

Added [hotkey.language_modifiers], which maps modifier keys to Whisper language codes. Holding one during a recording transcribes that recording in that language, and the next goes back to the configured one.

· +119 −1 · 5 files

Added a loop check to the Whisper transcriber. When a phrase of three or more words repeats three or more times in a row, it retries with beam search, and keeps one copy of each phrase if the retry loops too.

· +79 −1 · 1 file

Fixed picking in the 3D view on fractional-scale screens. toDevicePixelPosition() flipped the Y axis in logical pixels and only then scaled to device pixels, so on a 2.67x panel a click landed 1 to 4 device pixels below the cursor.

· +7 −2 · 1 file

Added WISPR_CAPTURE_IGNORE_VK, a list of virtual-key codes the Linux helper leaves out of its global key monitor, so a push-to-talk bind on Right Ctrl stops firing on Left Ctrl as well. Skipped keys do not take a sequence index, so the app never sees a gap, and paste injection is untouched.

· +114 −0 · 2 files

Added on-enter and on-leave command hooks to every Waybar module, requested since 2023. Ignores GTK's inferior crossings and latches on a hover flag so each command runs once per hover, and mirrors the on-click guard that keeps built-in actions from also running as shell commands.

· +312 −1 · 36 files

Packaged claude-auto-retry as a Nix flake with NixOS and home-manager modules that build from source and manage the shell wrapper, reconcile timer, and StopFailure hook through real options instead of npm i -g. Also fixed the wrapper clobbering each tmux pane's TERM.

· +404 −3 · 10 files

Add git rebase --continue --no-edit and rebase.noEdit to skip opening $EDITOR after resolving conflicts, with --edit to override the config for a single continue.

· +126 −7 · 5 files

Modernized XML_XRD with PSR-4 autoloading under an XRD namespace, replacing legacy PEAR layout.

· +346 −314 · 25 files

Added setInteractive on Leaflet layers so interactivity can be toggled after creation.

· +465 −20 · 10 files

Made SVGO's optimize return the AST and exported parseSvg in the browser bundle to avoid double parsing.

· +49 −1 · 3 files

Updated XML_XRD tests to PHPUnit 9.5 as part of broader PEAR modernization.

· +90 −108 · 10 files

Let Leaflet controls use role="button" instead of requiring anchor elements in control markup.

· +29 −13 · 1 file

Added a GNU Stow option to emit absolute symlinks when packages must resolve outside the stow tree.

· +28 −5 · 2 files

Let capistrano-db-tasks pass extra mysqldump flags—needed for schemas where a non-unique key follows the primary key.

· +24 −5 · 5 files

Added missing return types on XML_XRD to clear PHP 8 deprecation warnings.

· +7 −7 · 2 files

Clamped negative whiptail menu heights that broke item rendering from v0.52.22 onward.

· +1 −1 · 1 file

Clamped negative whiptail menu heights that broke item rendering from v0.52.22 onward.

· +1 −1 · 1 file

Added cartesian_product and InvalidArgumentException::assertStringable to functional-php.

Added copy/paste shortcuts to the Rete.js keyboard plugin for moving node selections between editor states.

Opened the Rete.js context menu at the pointer and clamped it to the viewport edges.

Switched Rete.js shortcuts from event.code to event.key so remapped and non-QWERTY layouts work.

Added a redshift i3blocks module that shows the current screen colour temperature.

Closed 27

Updated react-scoped-css for Webpack 5 and CRACO 7 without deprecated loader APIs. Closed as implemented by the maintainer

· +10329 −11380 · 52 files

Wrapped every Intl stub class in a class_exists guard, so loading the polyfill next to a real intl extension stops fataling with "Cannot declare class Normalizer, because the name is already in use".

· +190 −156 · 17 files

Let React Leaflet's LayersControl instantiate a custom L.Control subclass—needed for image-based layer toggles.

· +310 −26 · 4 files

Added max-height and max-width props to BDropdown so a menu can override the size @floating-ui/vue works out for it, with an example in the docs.

· +85 −2 · 4 files

Passed drag-and-drop context into dnd-kit sorting strategies so custom sorters can read sensors and collisions.

· +33 −15 · 3 files

Added initial and delta pointer coordinates to dnd-kit collision detection for frame-to-frame movement.

· +23 −3 · 4 files

Allowed optional altitude in Leaflet's LatLngTuple typing.

· +16 −4 · 2 files

Bumped gtk4 to 4.22.5 for upstream's munmap() fix in the Wayland dmabuf format table, which took down any GTK4 client on Hyprland whenever a monitor was plugged in. Traced from a coredump and first sent as a backported patch; the reviewer asked for the bump once 4.22.5 was tagged. Closed as a duplicate of #559510, the GNOME 51 update on staging, which takes gtk4 to 4.24.0.

· +2 −16 · 1 file

Fixed a malformed Markdown table in the Seastar README.

· +6 −6 · 1 file

Synced Leaflet marker interactive when the React Leaflet prop changes after mount.

· +10 −0 · 1 file

Let BootstrapVueNextResolver take a map of component aliases, so a project moving off bootstrap-vue can keep auto-importing the names it already uses.

· +7 −3 · 1 file

Bound the missing clear event when the Rete.js history plugin installs, so clearing the history no longer throws (retejs/history-plugin#16).

· +1 −0 · 1 file

Added explicit return types on Symfony overrides in Codeception to silence Symfony 5.4 deprecations.

Updated getopt-php type signatures for PHP 8.1 and PHPStan cleanliness.

Upgraded cte-builder to Doctrine 3 and beberlei/assert 3.3. Closed after the maintainer folded parts of it into 3.1.0, which supports DBAL 2 and 3 side by side

Bumped Android Gradle tooling in react-native-screens to unblock gesture-handler builds.

Bumped Android Gradle tooling in the TypeScript RN template to fix Groovy/Java7 build failures.

Documented Symfony Translator ICU message support alongside symfony#37371.

Added an aur-update2 i3blocks module that surfaces pending AUR upgrades via pikaur.

Packaged php-align for MELPA with a recipe covering its PHP alignment rules. Closed once the thread led to php-align.el being merged into php-mode instead

Added a PHP CS Fixer option to keep single-line autogenerated getters and setters.

Fixed IncludeOS build-system issues blocking a clean CI build.

Repaired a php-cs-fixer wrapper for Emacs, swapping its broken patch step for a direct file fix and a buffer revert.

Lets git mv create missing leading directories in the destination instead of failing: git mv -p foo bar/quux/ creates bar/ and bar/quux/ first, then moves foo into place.

Argued for restore.defaultDestination on the Git mailing list (mirrored as git/git#1467) so users control where restored files land during recovery.

Fixed bbswitch kernel-module packaging and build issues for NVIDIA GPU power switching in a personal fork, never submitted upstream.

Fixed and extended GNU FM (Libre.fm) PHP code in a fork of the Savannah-hosted music platform, never submitted upstream.