homepage iniT

This commit is contained in:
2026-08-23 20:00:09 +02:00
commit a1cd25c756
72 changed files with 5978 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
root = true
[*]
indent_style = space
max_line_length = 120
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false
[Makefile]
indent_style = tab
indent_size = 8
[*.md]
insert_final_newline = true
trim_trailing_whitespace = true
indent_size = 2
[*.{toml,json,yaml,yml}]
indent_size = 2
[*.{html,css,scss}]
indent_size = 2
+9
View File
@@ -0,0 +1,9 @@
.hugo_build.lock
.vscode/
hugo/
public/
build/
resources/_gen/
node_modules/
.DS_Store
.vercel
+1
View File
@@ -0,0 +1 @@
static/css/styles.css
+20
View File
@@ -0,0 +1,20 @@
{
"printWidth": 80,
"singleQuote": false,
"bracketSameLine": true,
"proseWrap": "preserve",
"plugins": ["prettier-plugin-go-template"],
"overrides": [
{
"files": ["*.html"],
"options": {
"printWidth": 180,
"parser": "go-template",
"singleQuote": false,
"goTemplateBracketSpacing": true,
"bracketSameLine": true
}
}
],
"goTemplateBracketSpacing": true
}
+660
View File
@@ -0,0 +1,660 @@
# GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc.
<https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.
## Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains
free software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing
under this license.
The precise terms and conditions for copying, distribution and
modification follow.
## TERMS AND CONDITIONS
### 0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public
License.
"Copyright" also means copyright-like laws that apply to other kinds
of works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of
an exact copy. The resulting work is called a "modified version" of
the earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user
through a computer network, with no transfer of a copy, is not
conveying.
An interactive user interface displays "Appropriate Legal Notices" to
the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
### 1. Source Code.
The "source code" for a work means the preferred form of the work for
making modifications to it. "Object code" means any non-source form of
a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users can
regenerate automatically from other parts of the Corresponding Source.
The Corresponding Source for a work in source code form is that same
work.
### 2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not convey,
without conditions so long as your license otherwise remains in force.
You may convey covered works to others for the sole purpose of having
them make modifications exclusively for you, or provide you with
facilities for running those works, provided that you comply with the
terms of this License in conveying all material for which you do not
control copyright. Those thus making or running the covered works for
you must do so exclusively on your behalf, under your direction and
control, on terms that prohibit them from making any copies of your
copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under the
conditions stated below. Sublicensing is not allowed; section 10 makes
it unnecessary.
### 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such
circumvention is effected by exercising rights under this License with
respect to the covered work, and you disclaim any intention to limit
operation or modification of the work as a means of enforcing, against
the work's users, your or third parties' legal rights to forbid
circumvention of technological measures.
### 4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
### 5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these
conditions:
- a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
- b) The work must carry prominent notices stating that it is
released under this License and any conditions added under
section 7. This requirement modifies the requirement in section 4
to "keep intact all notices".
- c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
- d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
### 6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms of
sections 4 and 5, provided that you also convey the machine-readable
Corresponding Source under the terms of this License, in one of these
ways:
- a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
- b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the Corresponding
Source from a network server at no charge.
- c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
- d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
- e) Convey the object code using peer-to-peer transmission,
provided you inform other peers where the object code and
Corresponding Source of the work are being offered to the general
public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal,
family, or household purposes, or (2) anything designed or sold for
incorporation into a dwelling. In determining whether a product is a
consumer product, doubtful cases shall be resolved in favor of
coverage. For a particular product received by a particular user,
"normally used" refers to a typical or common use of that class of
product, regardless of the status of the particular user or of the way
in which the particular user actually uses, or expects or is expected
to use, the product. A product is a consumer product regardless of
whether the product has substantial commercial, industrial or
non-consumer uses, unless such uses represent the only significant
mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to
install and execute modified versions of a covered work in that User
Product from a modified version of its Corresponding Source. The
information must suffice to ensure that the continued functioning of
the modified object code is in no case prevented or interfered with
solely because modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or
updates for a work that has been modified or installed by the
recipient, or for the User Product in which it has been modified or
installed. Access to a network may be denied when the modification
itself materially and adversely affects the operation of the network
or violates the rules and protocols for communication across the
network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
### 7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders
of that material) supplement the terms of this License with terms:
- a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
- b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
- c) Prohibiting misrepresentation of the origin of that material,
or requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
- d) Limiting the use for publicity purposes of names of licensors
or authors of the material; or
- e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
- f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions
of it) with contractual assumptions of liability to the recipient,
for any liability that these contractual assumptions directly
impose on those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions; the
above requirements apply either way.
### 8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your license
from a particular copyright holder is reinstated (a) provisionally,
unless and until the copyright holder explicitly and finally
terminates your license, and (b) permanently, if the copyright holder
fails to notify you of the violation by some reasonable means prior to
60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
### 9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run
a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
### 10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
### 11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims owned
or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within the
scope of its coverage, prohibits the exercise of, or is conditioned on
the non-exercise of one or more of the rights that are specifically
granted under this License. You may not convey a covered work if you
are a party to an arrangement with a third party that is in the
business of distributing software, under which you make payment to the
third party based on the extent of your activity of conveying the
work, and under which the third party grants, to any of the parties
who would receive the covered work from you, a discriminatory patent
license (a) in connection with copies of the covered work conveyed by
you (or copies made from those copies), or (b) primarily for and in
connection with specific products or compilations that contain the
covered work, unless you entered into that arrangement, or that patent
license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
### 12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under
this License and any other pertinent obligations, then as a
consequence you may not convey it at all. For example, if you agree to
terms that obligate you to collect a royalty for further conveying
from those to whom you convey the Program, the only way you could
satisfy both those terms and this License would be to refrain entirely
from conveying the Program.
### 13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your
version supports such interaction) an opportunity to receive the
Corresponding Source of your version by providing access to the
Corresponding Source from a network server at no charge, through some
standard or customary means of facilitating copying of software. This
Corresponding Source shall include the Corresponding Source for any
work covered by version 3 of the GNU General Public License that is
incorporated pursuant to the following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
### 14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Affero General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever
published by the Free Software Foundation.
If the Program specifies that a proxy can decide which future versions
of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
### 15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT
WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND
PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE
DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR
CORRECTION.
### 16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR
CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES
ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT
NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR
LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM
TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER
PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
### 17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
## How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these
terms.
To do so, attach the following notices to the program. It is safest to
attach them to the start of each source file to most effectively state
the exclusion of warranty; and each file should have at least the
"copyright" line and a pointer to where the full notice is found.
Luciano Nooijen's Personal Website and Blog
Copyright (C) 2025 Luciano Nooijen
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper
mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for
the specific requirements.
You should also get your employer (if you work as a programmer) or
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. For more information on this, and how to apply and follow
the GNU AGPL, see <https://www.gnu.org/licenses/>.
+28
View File
@@ -0,0 +1,28 @@
.PHONY: help
help: ## Shows all commands
@echo 'All Makefile commands:'
@grep -h -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
.PHONY: server
server: ## Start development server
hugo server
.PHONY: css
css: ## Build css file
npm run build:css
.PHONY: csswatch
csswatch: ## Run listener for scss changes, compiling on the fly for development
npm run dev:css
.PHONY: fmt
fmt: ## Format project using Prettier
npm run fmt
.PHONY: dev
dev: ## Opens browser and starts server
open http://localhost:1313 && hugo server
.PHONY: build
build: css fmt ## Builds production website for lucianonooijen.com
hugo -d build -b https://lucianonooijen.com --minify --gc
+38
View File
@@ -0,0 +1,38 @@
# Luciano Nooijen personal website
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
Luciano Nooijen's personal website built with [Hugo](https://gohugo.io).
## Development
Run `npm install` to install development dependencies.
For development, run `hugo server`. The development environment is now accessable on `localhost:1313`.
You can also just use `open http://localhost:1313 && hugo server`, but not all systems support this.
## Production
As Hugo is a static website builder, you can just host the website on any hosting service. To build the static files, run `rm -rf build && hugo -d build -b [url]`. I run `hugo -d build -b https://lucianonooijen.com`.
Now upload the contents of the `build` folder to the webhost and you're done.
When using `make build` it also builds the css and runs the formatter before building.
## Make
The `make` command can also be used:
| Command | Functionality |
| ------------- | ------------------------------------------------------------------ |
| `make server` | Runs hugo server |
| `make css` | Converts scss to css |
| `make dev` | Runs hugo server and opens `localhost:1313` in browser |
| `make build` | Runs hugo build command as explained under the "production" header |
| `make fmt` | Runs Prettier formatting for all files (should be in PATH) |
### License
- [AGPL3.0](https://www.gnu.org/licenses/agpl-3.0) for all website code
- [CC-BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) for text content
+4
View File
@@ -0,0 +1,4 @@
# TODOs
- [ ] Add Capsa into experience somewhere
- [ ] Dark mode support
+6
View File
@@ -0,0 +1,6 @@
+++
draft = true
date = {{ .Date }}
title = ""
slug = ""
+++
+8
View File
@@ -0,0 +1,8 @@
+++
draft = true
date = {{ .Date }}
title = ""
slug = ""
tags = []
categories = []
+++
+161
View File
@@ -0,0 +1,161 @@
#### Freelance
_08/2021 - present_ ~ **Lead/staff software engineer, freelance**
I currenly work as a freelance software engineer, in either lead or staff positions. A dedicated page for which can be found [here](/freelance/).
#### Companion Group Ltd.
_11/2025 - present_ ~ **Staff engineer for [AA, MMO]**
_Details will be added in due time._
_09/2025 - 11/2025_ ~ **Gameplay engineer for Storm Lancers (ProbablyMonsters)**
Worked on Xbox+DualSense controller input support for PC port of the game, both for EGS (custom logic with RawInput) and Steam (Steam Input API). Additionally contributed to Steamdeck support.
_05/2025 - 09/2025_ ~ **Senior game backend/SRE engineer for Project Ghost (Fantastic Pixel Castle)**
- Implementation of back-end functionality (C++)
- Containerization of game server and services (Docker, C++, UE5)
- Migrating infrastructure to infrastructure-as-code (Terraform, AWS)
- Remote log persistence setup for game server and services (C++, UE5)
- Contributions to custom build system and local development workflow
- Development of various internal tools and APIs
_01/2025 - 05/2025_ ~ **AI bot engineer, Unreal Engine for [AA]**
Implementation of AI bots based on behavior trees, with support for game abilities (GAS) by bots. I added support for combat with various weapons and implemented bot spawning based on level settings with support for loadouts. Various game logic was modified to be compatible with bots.
_10/2024 - present_ ~ **Lead developer, maintainer for [capsa.gg](https://capsa.gg) (open-source)**
[Capsa](https://capsa.gg) is a non-intrusive open-source logging solution for Unreal Engine. For Capsa, I created the API server and web panel from scratch and contributed to the Unreal Engine plugin. Additionally, I created the documentation website and a video playlist for demo and integration guide.
_04/2024 - 12/2024_ ~ **Gears of War: E-Day (The Coalition/Microsoft)**
Supporting Online and Platform Services. _Details will be added in due time._
_08/2023 - 03/2024_ ~ **Senior game backend engineer for [AAA, Amazon Games, unannounced]**
- Containerization of game servers and integration with CI/CD (Docker, GitLab)
- Development of custom game server orchestration solution (C# .NET 6, SAM, DynamoDB)
- Integration of game server orchestration into game server code (C++/Unreal Engine)
- Prototype of real-time back-end service to game server communication (MQTT, C++, Unreal Engine)
- Integration of Amazon GameLift into custom Online Subsystem (AWS, C++/Unreal Engine)
- Development of API server between players and GameLift (Golang, JWT, JWK, Kubernetes, Helm)
- Game server infrastructure and deployment automation (Terraform, AWS, GitLab, Scripting)
_08/2023 - 04/2024_ ~ **Development lead (part-time) for prototype of Project Dual**
- Initial set-up of prototype in Unreal Engine 5.2
- Development of proof-of-concept of TeamManager subystem (C++/Unreal Engine)
- Creation of various game elements and levels (Unreal Engine Blueprints)
- Involved with technical oversight and support, advice for production version of game
_01/2023 - 08/2023_ ~ **Online game client engineer for VAIL VR (AEXLAB)**
- Migrating to Armada game server orchestration; client, server and CI/CD side implementation
- Implementation of various SDKs into game client and server code (C++/Unreal Engine)
- Guiding adoption and implementation of AccelByte services into the game (C++/Unreal Engine)
- Zero-downtime migration of PlayFab to AccelByte for authentication and entitlements (Golang)
- Implemented game server player progression: stats tracking/uploads, MMR, rewards (C++)
- Development lead for server browser, matchmaking, player progression and marketplace
#### CoolGames B.V.
_05/2022 - 10/2022_ ~ **Software and data engineer**
Migration of millions of player accounts across multiple games to a new backend system, with near-zero downtime. Built custom data export/import pipelines between Gamesparks and Nakama, with high performance. In addition, I made several DevOps/SRE improvements and supporting tools.
#### Bytecode Digital Agency B.V.
_05/2018 - 03/2022_ ~ **Co-founder and techlead**
Together with a business partner, I founded Bytecode Digital Agency. Our office was located in Delfgauw. Our team specialized in custom application development, mostly for software start-ups targeting web and mobile.
As technical lead, my activities were mostly focused on building the network layers between different applications, as well as hands-on development on back-end applications. Additionally, I worked on internal software, cyber security and cloud infrastructure. In August 2021, I announced my exit from Bytecode.
#### VNG Realisering, Team NLX
_08/2021 - 12/2021_ ~ **Senior software engineer**
I worked on the NLX project, an open-source software project that allows data sharing between organisations in a safe, secure and privacy-proof way. This included work on the back-end (Go), front-end (React) and SRE (Kuebernetes, Helm), improvements in development and build setup to improve development experience and speed. I have introduced Typescript into the React codebase and build systems and provided React and Typescript training to back-end developers. Part of my contributions are public [on GitLab](https://gitlab.com/commonground/nlx/nlx/-/commits/master?search=Luciano).
#### Airchip B.V.
_02/2021 - 11/2021_ ~ **CTO, ad interim**
Airchip was a startup that aimed to revolutionize digital ordering of drinks at festivals, even without internet connection. At Airchip, I fulfilled the role of CTO on an ad-interim basis (via Bytecode). My responsibilities included hands-on development of the back-end application (Go) and taking care of the site reliability engineering. I also had an advisory role for the three native mobile applications (Kotlin, Swift) and the BLE-protocol.
#### Dearly B.V.
_06/2020 - 11/2021_ ~ **Partner and techlead, ad interim**
Dearly is an initiative to help people with deal with grief and making grief more accessible to talk about, both in private and business setting. Similarly to Yourpwr, I was responsible for the technical side of the Dearly platform and advisor.
#### Youngpwr B.V.
_02/2020 - 08/2021_ ~ **Partner and techlead, ad interim**
Youngpwr is an initiative to support the development of entrepreneurial skills in young people. I was responsible for the technical side of the Youngpwr platform, which included software-architecture, infrastructure, systems integration, data engineering, security and code quality. Part of the management team (board member) and technical advisor.
#### Eneco / Dept Agency
_05/2019 - 03/2020_ ~ **Software engineer, ad interim**
Leading frontend developer for De Omschakelaar project at Eneco, an innovative tool that played a crucial role in the customer journey and promoted in several big marketing campaigns. I created several code generation tools and tackled technical debt. The technology stack used is mainly based on Typescript/Javascript and ReactJS.
#### Stager Software
_12/2017 - 05/2018_ ~ **Full-stack software engineer**
During my time working at Stager, I worked as a full-stack (with focus on the front-end) developer on the main Stager ticketing application. Technologies I used here were mostly ReactJS and Play! Framework (Java).
#### Nooijen Web Solutions
_05/2015 - 05/2018_ ~ **Owner, founder and developer**
Nooijen Solutions is a web designing/develop business that aims to creates beautiful websites and web apps. I worked here as one of the main developers (Wordpress, html, css, php, js, server-work) and I also do all of the administrative work.
#### Chemiewinkel
_07/2014 - 12/2018_ ~ **Owner and founder**
In the summer vacation of 2014, I started Chemiewinkel. The reason why I started a webshop in lab supplies as a 15-year-old, was because I was (and still am!) fascinated by chemistry, and supplies for hobby chemists were very hard to come by. I built and managed the webshop.
## Volunteer experience
#### Bookclub Philosophy
_06-2021 - present_ ~ **Reading group leader, moderator**
Leading the reading groups on Stoicism, Aristotle and general book discussion group.
Other resonsibilities include Discord server administration, development and maintenance of the [custom-made bot](https://github.com/lucianonooijen/socrates-discord-bot), helping out new members and automating tasks.
#### Gaga Animal Care
_04/2017 - present_ ~ **Board member, webmaster**
Volunteer for the dog shelter in Greece. Involved with planning and executing multiple neutering programs each year.
Other responsibilities include website creation and maintenance.
#### Pijnackerse Watervienden
_2013 - 2015_ ~ **Swimming tutor assistant**
Leading and assisting swimming lessons for young children.
#### Atlas Animal Project
_10/2013 - 12/2015_ ~ **Volunteer**
Occasionally assisting Atlas representatives, usually at Schiphol airport with dogs that arrived or collecting dog crates and returning them to Kos via cargo.
#### Animal Rescue Kos
_03/2013_ ~ **Documentary director**
Assisting volunteers living on Kos and filming the activities. After filming I edited, released and promoted the documentary.
<!--Finished documentary: bit.ly/arkfilm or bit.ly/arkfilmgermany-->
+101
View File
@@ -0,0 +1,101 @@
<style>
.gametitles-fallback { display: none }
.gametitles {
display: block;
width: min(160rem, 90vw);
}
@media screen and (max-width: 1000px) {
.gametitles-fallback { display: block }
.gametitles { display: none }
}
</style>
<p class="gametitles-fallback"><i>Mobile not supported</i></p>
<table class="gametitles">
<thead>
<tr>
<th>Type</th>
<th>Game</th>
<th>Genre</th>
<th>Studio/Publisher</th>
<th>Technology</th>
<th>Position/work</th>
</tr>
</thead>
<tbody>
<tr>
<td>AA</td>
<td><i>NDA</i></td>
<td>MMO</td>
<td><i>NDA</i></td>
<td>Go, React, UE5</td>
<td>Staff engineer</td>
</tr>
<tr>
<td>AA</td>
<td>Storm Lancers</td>
<td>Roguelite</td>
<td>Probably Monsters</td>
<td>C++, UE5</td>
<td>Gameplay engineer (controller input)</td>
</tr>
<tr>
<td>AAA</td>
<td>Project Ghost</td>
<td>MMORPG</td>
<td>Fantastic Pixel Castle</td>
<td>C++, Modified UE5</td>
<td>Senior backend/SRE engineer</td>
</tr>
<tr>
<td>AA</td>
<td><i>NDA</i></td>
<td>Survival/PvP</td>
<td><i>NDA</i></td>
<td>UE5, C++ </td>
<td>Game client AI bot engineer</td>
</tr>
<tr>
<td>AAA</td>
<td>Gears of War: E-Day</td>
<td>Shooter</td>
<td>The Coalition</td>
<td><i>NDA</i></td>
<td>Online and Platform Services (tech lead)</td>
</tr>
<tr>
<td>Indie</td>
<td>Project Dual</td>
<td>Platformer</td>
<td>Companion Group</td>
<td>UE5, C++</td>
<td>Lead developer prototype</td>
</tr>
<tr>
<td>AAA</td>
<td><i>Unannounced</i></td>
<td>PvP/PvE</td>
<td>Amazon Games</td>
<td>UE5, C++, .NET</td>
<td>Game backend engineer</td>
</tr>
<tr>
<td>AA</td>
<td>VAIL VR</td>
<td>VR FPS</td>
<td>AEXLAB, Meta</td>
<td>UE4/5, C++</td>
<td>Online Client Engineer</td>
</tr>
<tr>
<td>AA</td>
<td>Several</td>
<td>Casual</td>
<td>Coolgames</td>
<td>Nakama/HTML5</td>
<td>Data migration engineer</td>
</tr>
</tbody>
</table>
+41
View File
@@ -0,0 +1,41 @@
## Technologies used
The technologies I'm currently working with the most and am most familiar with are in **bold**.
### Game development
- Client: **Unreal Engine 5** (C++, Blueprints), C, C++
- Backend: **C++** (Unreal Engine, custom), **C#** (.NET), **Go**
### Backend/server development
- **Golang** (Gin, Echo, Stdlib)
- Typescript (NodeJS, Deno, Bun)
- C# (.NET)
- Elixir (Phoenix, OTP)
_Most often with **PostgreSQL** as the underlying database._
### Front-end and mobile development
- **React with Typescript** (NextJS)
- Web Assembly (Golang, Zig, Odin, Jai, C/C++)
- React Native (Expo or bare)
- Progressive Web Apps
- Elm
### Site Reliability Engineering and DevOps
- **Linux** (Ubuntu, Debian, Arch, CentOS)
- **CI/CD** (GitLab, GitHub Actions, Azure DevOps, Jenkins)
- Cloud (DigitalOcean, AWS, Azure, GCP)
- Docker and Kubernetes (Compose, Helm)
- Infrastructure and provisioning (Ansible, Terraform, Shell)
- Systems integration
- Nginx
### Domain specific
- **Compilers, parsers, DSLs**: Golang, C, C++
- Systems programming: C, C++, Zig, Odin, Jai
- Data science: SQL, Python, Javascript/Typescript
+15
View File
@@ -0,0 +1,15 @@
## Working method
_Methods I employ to deliver elegant, efficient, readable, understandable and maintainable code that adds value to a company and/or product._
- "Always choose the best tool for the job"
- Cloud and application structure using [The Twelve-Factor App](https://12factor.net/)
- Software architecture via a simplified version of the principles of [The Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html), [DDD](https://en.wikipedia.org/wiki/Domain-driven_design) and [Layered Architecture](https://en.wikipedia.org/wiki/Multitier_architecture).
- Prevention of [technical debt](https://en.wikipedia.org/wiki/Technical_debt), short term and long term
- Everything (even [infrastructure as code](https://en.wikipedia.org/wiki/Infrastructure_as_code)) in version control (Git/Perforce)
- Project management via SCRUM/Agile principle, without unnecessary meetings
- Bringing together Development, Security and Operations through [DevSecOps](https://www.devsecops.org/) and [secure by design](https://en.wikipedia.org/wiki/Secure_by_design).
- Test-Driven Development (TDD) of domain logic where it makes sense
- Versioning according to [Semantic Versioning](https://semver.org/)
- Adhere to the [ACM Code of Ethics](https://www.acm.org/code-of-ethics)
- Use of free (open-source) software where possible, contributing when possible
+351
View File
@@ -0,0 +1,351 @@
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
/* Document
========================================================================== */
/**
* 1. Correct the line height in all browsers.
* 2. Prevent adjustments of font size after orientation changes in iOS.
*/
html {
line-height: 1.15; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
}
/* Sections
========================================================================== */
/**
* Remove the margin in all browsers.
*/
body {
margin: 0;
}
/**
* Render the `main` element consistently in IE.
*/
main {
display: block;
}
/**
* Correct the font size and margin on `h1` elements within `section` and
* `article` contexts in Chrome, Firefox, and Safari.
*/
h1 {
font-size: 2em;
margin: 0.67em 0;
}
/* Grouping content
========================================================================== */
/**
* 1. Add the correct box sizing in Firefox.
* 2. Show the overflow in Edge and IE.
*/
hr {
box-sizing: content-box; /* 1 */
height: 0; /* 1 */
overflow: visible; /* 2 */
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
pre {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/* Text-level semantics
========================================================================== */
/**
* Remove the gray background on active links in IE 10.
*/
a {
background-color: transparent;
}
/**
* 1. Remove the bottom border in Chrome 57-
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
*/
abbr[title] {
border-bottom: none; /* 1 */
text-decoration: underline; /* 2 */
text-decoration: underline dotted; /* 2 */
}
/**
* Add the correct font weight in Chrome, Edge, and Safari.
*/
b,
strong {
font-weight: bolder;
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/**
* Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/**
* Prevent `sub` and `sup` elements from affecting the line height in
* all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/* Embedded content
========================================================================== */
/**
* Remove the border on images inside links in IE 10.
*/
img {
border-style: none;
}
/* Forms
========================================================================== */
/**
* 1. Change the font styles in all browsers.
* 2. Remove the margin in Firefox and Safari.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit; /* 1 */
font-size: 100%; /* 1 */
line-height: 1.15; /* 1 */
margin: 0; /* 2 */
}
/**
* Show the overflow in IE.
* 1. Show the overflow in Edge.
*/
button,
input {
/* 1 */
overflow: visible;
}
/**
* Remove the inheritance of text transform in Edge, Firefox, and IE.
* 1. Remove the inheritance of text transform in Firefox.
*/
button,
select {
/* 1 */
text-transform: none;
}
/**
* Correct the inability to style clickable types in iOS and Safari.
*/
button,
[type="button"],
[type="reset"],
[type="submit"] {
-webkit-appearance: button;
}
/**
* Remove the inner border and padding in Firefox.
*/
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
border-style: none;
padding: 0;
}
/**
* Restore the focus styles unset by the previous rule.
*/
button:-moz-focusring,
[type="button"]:-moz-focusring,
[type="reset"]:-moz-focusring,
[type="submit"]:-moz-focusring {
outline: 1px dotted ButtonText;
}
/**
* Correct the padding in Firefox.
*/
fieldset {
padding: 0.35em 0.75em 0.625em;
}
/**
* 1. Correct the text wrapping in Edge and IE.
* 2. Correct the color inheritance from `fieldset` elements in IE.
* 3. Remove the padding so developers are not caught out when they zero out
* `fieldset` elements in all browsers.
*/
legend {
box-sizing: border-box; /* 1 */
color: inherit; /* 2 */
display: table; /* 1 */
max-width: 100%; /* 1 */
padding: 0; /* 3 */
white-space: normal; /* 1 */
}
/**
* Add the correct vertical alignment in Chrome, Firefox, and Opera.
*/
progress {
vertical-align: baseline;
}
/**
* Remove the default vertical scrollbar in IE 10+.
*/
textarea {
overflow: auto;
}
/**
* 1. Add the correct box sizing in IE 10.
* 2. Remove the padding in IE 10.
*/
[type="checkbox"],
[type="radio"] {
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
}
/**
* Correct the cursor style of increment and decrement buttons in Chrome.
*/
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
/**
* 1. Correct the odd appearance in Chrome and Safari.
* 2. Correct the outline style in Safari.
*/
[type="search"] {
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/**
* Remove the inner padding in Chrome and Safari on macOS.
*/
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* 1. Correct the inability to style clickable types in iOS and Safari.
* 2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
/* Interactive
========================================================================== */
/*
* Add the correct display in Edge, IE 10+, and Firefox.
*/
details {
display: block;
}
/*
* Add the correct display in all browsers.
*/
summary {
display: list-item;
}
/* Misc
========================================================================== */
/**
* Add the correct display in IE 10+.
*/
template {
display: none;
}
/**
* Add the correct display in IE 10.
*/
[hidden] {
display: none;
}
+29
View File
@@ -0,0 +1,29 @@
@mixin max-xs() {
@media screen and (max-width: 400px) {
@content;
}
}
@mixin max-sm() {
@media screen and (max-width: 600px) {
@content;
}
}
@mixin max-md() {
@media screen and (max-width: 800px) {
@content;
}
}
@mixin max-lg() {
@media screen and (max-width: 1000px) {
@content;
}
}
@mixin max-xl() {
@media screen and (max-width: 1200px) {
@content;
}
}
+21
View File
@@ -0,0 +1,21 @@
//
// Colours
//
$color-bg: #efebe0;
$color-text: #2e2b26;
$color-heading: #2e2b26;
$color-link: #8b4513;
$color-link-active: #cd4416;
$color-code-text: #223244;
$color-code-bg: #dadada;
$color-quote: #666;
//
// Widths
//
$container-max-width: 120rem;
$blogpost-width: 80rem;
+84
View File
@@ -0,0 +1,84 @@
@use "_vars.scss";
@use "_responsive.scss";
*,
*:after,
*:before {
box-sizing: inherit;
}
html {
box-sizing: border-box;
font-size: 62.5%; // Set base to 10px
}
body {
display: flex;
color: vars.$color-text;
background-color: vars.$color-bg;
font-family: "Noto Sans Mono", monospace;
font-size: 1.8em;
font-weight: 400;
letter-spacing: 0.032em;
line-height: 1.8em;
@include responsive.max-md() {
font-size: 1.4em;
line-height: 1.6em;
}
}
img {
max-width: 100%;
}
.container {
margin: 0 auto;
max-width: vars.$container-max-width;
width: 100%;
padding-left: 2rem;
padding-right: 2rem;
}
.wrapper {
display: flex;
flex-direction: column;
min-height: 100vh;
width: 100%;
}
.content {
flex: 1;
margin-top: 1.6rem;
margin-bottom: 2.4rem;
}
.centered {
display: flex;
height: 100%;
align-items: center;
justify-content: center;
}
//
// Print
//
@media print {
@page {
size: auto;
margin: 0;
}
nav,
footer {
display: none !important;
}
html {
font-size: 36%;
padding: 32px;
}
.content {
margin-top: -24px;
}
}
+45
View File
@@ -0,0 +1,45 @@
@use "_vars.scss";
@use "_responsive.scss";
.list {
ul {
list-style: none;
margin: 4rem 0;
padding: 0;
li {
display: flex;
justify-content: space-between;
align-items: center;
flex-direction: row-reverse;
padding: 0.4rem 0;
border-bottom: 1px solid #bbb;
span {
display: block;
width: 100%;
max-width: 20rem;
margin-left: 3rem;
}
a {
color: vars.$color-text;
line-height: 1.1;
}
}
@include responsive.max-md() {
li {
flex-direction: column;
align-items: flex-start;
border-bottom: none;
padding: 0.6rem 0;
span {
margin-left: 0;
font-size: 1.2rem;
}
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
@use "_vars.scss";
@use "_responsive.scss";
.block-experience {
h4 {
margin-bottom: 0.4rem;
}
p {
margin-top: 0;
margin-bottom: 0.8rem;
}
ul {
margin-top: 0;
margin-bottom: 2.4rem;
}
}
+67
View File
@@ -0,0 +1,67 @@
@use "_vars.scss";
@use "_responsive.scss";
//
// Menu base
//
.topnav {
height: 60px;
display: flex;
justify-content: space-between;
align-items: center;
}
.topnav-title {
color: vars.$color-text;
letter-spacing: 0;
}
.topnav-list {
list-style: none;
display: flex;
gap: 24px;
}
.topnav-link {
color: vars.$color-text;
letter-spacing: 0;
}
//
// Mobile menu
//
.mobilemenu {
display: none;
}
@include responsive.max-md() {
.mobilemenu {
display: initial;
color: vars.$color-text;
}
.topnav-list {
display: none;
gap: 0;
}
.topnav-list.navopen {
display: block;
position: absolute;
top: 60px;
right: 20px;
padding-left: 0 !important;
height: auto;
text-align: right;
background-color: #ddd;
display: flex;
flex-direction: column;
}
.topnav-item {
display: inline-block;
border-top: solid 1px #999;
min-width: 200px;
padding: 12px 24px;
}
.topnav-item:first-of-type {
border-top: none;
}
}
+59
View File
@@ -0,0 +1,59 @@
@use "_vars.scss";
@use "_responsive.scss";
article.blogpost {
max-width: vars.$blogpost-width;
margin-left: auto;
margin-right: auto;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased !important;
-moz-font-smoothing: antialiased !important;
text-rendering: optimizelegibility !important;
}
.post-content {
margin-top: 64px;
img {
width: 80%;
margin-left: 10%;
}
.highlight {
font-size: 14px;
line-height: 1.4;
}
@include responsive.max-md() {
.post-content {
margin-top: 36px;
}
.highlight {
font-size: 12px;
}
}
}
.endofpost {
max-width: 400px;
margin: 3em auto 3.4em;
display: block;
height: 1px;
border: 0;
border-top: 1px solid #888;
padding: 0;
}
a[href^="#fn:"]:before {
content: "[";
}
a[href^="#fn:"]:after {
content: "]";
}
.footnotes hr {
height: 1px;
border: 0;
border-top: 1px solid #888;
}
+31
View File
@@ -0,0 +1,31 @@
@use "_vars.scss";
table {
border-collapse: collapse;
td,
th {
text-align: left;
border: 1px solid vars.$color-quote;
padding: 0.8rem 1rem;
font-size: 1.6rem;
line-height: 1.2;
}
tr {
&:first-child th {
border-top: 0;
}
&:last-child td {
border-bottom: 0;
}
td:first-child,
th:first-child {
border-left: 0;
}
td:last-child,
th:last-child {
border-right: 0;
}
}
}
+195
View File
@@ -0,0 +1,195 @@
@use "sass:color";
@use "_vars.scss";
@use "_responsive.scss";
//
// Headings
//
h1,
h2,
h3,
h4,
h5,
h6 {
color: vars.$color-heading;
font-weight: 600;
margin: 3.2rem 0 1.4rem 0;
letter-spacing: 0.024em;
line-height: 1.1;
}
h1 {
font-size: 2.4em;
font-weight: 700;
}
h2 {
font-size: 2em;
}
h3 {
font-size: 1.6em;
}
h4 {
font-size: 1.4em;
}
h5 {
font-size: 1.2em;
}
h6 {
font-size: 1em;
}
@include responsive.max-md() {
h1 {
font-size: 2em;
}
h2 {
font-size: 1.8em;
}
h3 {
font-size: 1.6em;
}
h4 {
font-size: 1.4em;
}
h5 {
font-size: 1.2em;
}
h6 {
font-size: 1em;
}
}
//
// Body text
//
a {
font-weight: 600;
color: vars.$color-link;
text-decoration: none;
&:focus,
&:hover {
color: vars.$color-link-active;
text-decoration: underline;
}
}
p {
margin: 1.8rem 0;
text-shadow: 0.5px 0.5px 0px rgba(0, 0, 0, 0.05);
}
blockquote:not(.alert) {
border-left: 2px solid vars.$color-quote;
padding-left: 1.6rem;
font-style: italic;
p {
color: vars.$color-quote;
}
}
//
// ALERT BLOCKQUOTES
//
blockquote.alert {
padding: 0;
border: 2px solid vars.$color-quote;
background: vars.$color-code-bg;
border-radius: 1.4rem;
box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.05);
margin: 1rem -3.2rem;
.alert-heading {
display: flex;
flex-direction: row;
margin: 0;
background-color: vars.$color-quote;
border-radius: 1.2rem 1.2rem 0 0;
}
.alert-heading-emoji {
margin: 0.4rem 0;
width: 3.2rem;
text-align: center;
}
.alert-heading-text {
margin: 0.4rem 0;
}
> p {
padding: 1.6rem 3.2rem;
margin: 0;
}
@include responsive.max-md {
margin: 1rem -1.6rem;
.alert-heading-emoji {
width: 2.8rem;
}
> p {
padding: 1.2rem;
}
}
}
$alert-accents: (
tip: #388e3c,
note: #1976d2,
info: #244c91,
warning: #ffb700,
caution: #ff7b00,
danger: #d32f2f,
);
@each $name, $accent in $alert-accents {
blockquote.alert-#{$name} {
border: 2px solid color.mix(white, $accent, 70%);
background: color.mix(white, $accent, 90%);
.alert-heading {
background: color.mix(white, $accent, 70%);
color: #222;
font-weight: bold;
}
}
}
//
// Code
//
pre {
padding: 2.4rem 2rem;
white-space: pre-wrap;
word-wrap: break-word;
margin: 2.4rem -2rem;
@include responsive.max-md() {
margin: 2.4rem 0;
}
}
code {
color: vars.$color-code-text;
background-color: vars.$color-code-bg;
font-family: "Anonymous Pro", monospace;
font-weight: 500;
padding: 4px 8px;
}
.highlight {
line-height: 1.6;
code {
color: unset;
background-color: unset;
padding: 0;
font-weight: 400;
}
}
+14
View File
@@ -0,0 +1,14 @@
// Start new code
@use "normalize.scss";
@use "src/_vars.scss"; // Variables
@use "src/base.scss"; // Base styles, including layout etc.
@use "src/typography.scss"; // Typography setup
@use "src/navigation.scss"; // Navbar styling
@use "src/table.scss"; // Tables styling
@use "src/list.scss"; // Styles for list page
@use "src/post.scss"; // Blogpost specific code
@use "src/misc.scss"; // Misc styling for specific blocks etc
// End new code
+60
View File
@@ -0,0 +1,60 @@
baseurl = "https://lucianonooijen.com"
title = "Dr. Ing. Thomas Sindt"
languageCode = "de"
timeZone = "Europe/Berlin"
canonifyurls = true # Enable to turn relative URLs into absolute.
pygmentsstyle = "monokai"
pygmentscodefences = true
pygmentscodefencesguesssyntax = true
[markup]
defaultMarkdownHandler = "goldmark"
[markup.goldmark.parser.attribute]
block = true
[markup.goldmark.extensions]
table = true
footnote = true
[markup.goldmark.renderer]
unsafe = true
[params]
author = "Dr. Ing. Thomas Sindt"
info = "Projektleitung, Software, Musik und Fotografie"
description = "Mein Name ist Dr. Ing. Thomas Sindt, Projektleiter, praktizierender Philosoph (Informatiker), Teilzeitmusiker, manchmal Fotograf und lebenslanger Lerner"
keywords = "Thomas Sindt, Informatik, Philosophie, Musik, Fotografie, Projektleitung, Softwareentwicklung"
[[params.social]]
name = "Email"
weight = 1
url = "/contact"
[[params.social]]
name = "LinkedIn"
weight = 4
url = "https://www.linkedin.com/in/thomas-sindt-555820324"
# Menu links
[[menu.main]]
name = "Über mich"
weight = 1
url = "/about/"
[[menu.main]]
name = "Berufliche Expertise"
weight = 2
url = "/expertise/"
[[menu.main]]
name = "Musik & Fotografie"
weight = 3
url = "/experience/"
[[menu.main]]
name = "Blog"
weight = 4
url = "/blog/"
[[menu.main]]
name = "Kontakt"
weight = 5
url = "/contact/"
+54
View File
@@ -0,0 +1,54 @@
+++
title = "About me"
slug = "about"
+++
#### Introduction
My name is Luciano Nooijen, currently working in the games industry as an engineer focusing on online game client development, multiplayer functionality and engine programming.
My current position is senior/lead engineer at Companion Group, a co-development agency focussing on multiplayer games where I work on various projects and in various roles. With Companion Group, I've built an open-source logging solution for Unreal Engine multiplayer games, called [Capsa](https://capsa.gg). Some of the work I do is published on [my GitHub](https://github.com/lucianonooijen/).
I am also open to part-time freelance/consultancy work for multiplayer games, start-up consultancy or web development. More info can be found on my [freelance page](/freelance/).
#### Work
Although I was introduced to software engineering via website development, I have quickly moved more towards back-end development and site-reliability engineering. The closer to the computer hardware I get, the more I tend to enjoy the work I'm doing. Though I enjoy automating tasks, processing large amounts of data or optimizing data-intensive applications, I love working on compilers, low-level systems programming and game engines the most.
A few years ago, I was given the opportunity to enter the gaming industry, in which I currently work (details on my [experience page](/experience)), working mostly with Unreal Engine, C++ and various back-end technologies (mostly Golang, C#).
Where necessary, I use my experience in web development to assist in the development of internal web-based tooling used during the game development process, though this work to me is the exception rather than the rule.
#### Career
I started programming at an early age. In 2009 I made my first website, after having been interested in computers and software from an early age. After making this first website, I continued to learn more about website building, which enabled me to apply my skills on a professional level from 2014.
In 2014 I developed my own webshop and started to develop websites under the name Nooijen Web Solutions. After a while I started working with another developer with whom I later founded Bytecode, which evolved into a software start-up focussed digital agency. Through Bytecode, I have co-founded multiple start-ups, worked as technical lead in even more, leading the development of their software products.
#### Education
In 2017 I completed my VWO education at the Stanislascollege in Pijnacker. Afterwards I studied Computer Science and Engineering at Delft University of Technology for about half a year. However, the programme did not fit in well with the way I prefer to learn and I didn't feel challenged enough. I felt I could learn more and better in the way I always did, by myself and by doing, although I later realized this route has challenges of it's own.
In 2020, I came to a realization that even though I can make great impact with the work I do, there was still a feeling that there was more I could achieve. I considered going to University to get formal education about the true fundamentals to be able to understand what I do at the electrical engineering level, but I chose to go the self-education route here too.
When diving into the fundamentals, I started to delve into theoretical matters surrounding computer science, because I believe that to write software properly, you need to understand the tools and technologies that most people take for granted. Learning about this has changed how I approach writing software and has given me a much deeper appreciation of the craft.
On my dedicated [learning page](/learning) you can read more about my ongoing learning journey, the goals I want to achieve doing this and the path I take to get there, as well as the reasoning behind it all.
##### Certificates
In 2016 I obtained my Anglia Proficiency certificate with a "Distinction" grade. This shows that I master the English language on at least C1 level on the [CEFRL](https://en.wikipedia.org/wiki/Common_European_Framework_of_Reference_for_Languages) scale.
#### Leisure
In my spare time I read a lot of books, mostly non-fiction. My personal library mostly consists of books about philosophy, history, linguistics, the classics, and of course computer science and software engineering books.
Apart from reading and learning, I play a few video games and I like woodworking, gardening, cooking and sourdough baking.
I also like to play chess. Feel free to [challenge me on Lichess](https://lichess.org/@/lucianonooijen)
#### Website
The source code of this website is made available on my [Github profile](https://github.com/lucianonooijen/personal-website) under AGPL-3.0 license. Blogposts are CC-BY-SA licensed.
This website was developed using the static website generator Hugo (written in Golang).
+40
View File
@@ -0,0 +1,40 @@
+++
title = "Personal Development"
slug = "learning"
+++
_Archived at July 18th, 2020 and not updated thereafter._
#### Personal development
As a developer, it is important to always continue to learn and to make sure you keep up with the technological advancements. For this reason I spend at least one day a week on self-education.
###### What I am currently working on:
- Improvements within the infrastructure of Bytecode, applying Site Reliability Engineering (SRE) principles.
- Building a central API system (REST and gRPC) for Bytecode, consisting of several microservices.
- Creating code generation tools for project scaffolding (e.g. React and Redux) with the objective of more uniform code bases within Bytecode and less manual boilerplate code.
- Building a tool within Bytecode to define and test import boundaries with a domain specific language.
- Conduct research to create fixed "stacks" for development at Bytecode, and better definition of guidelines.
- Self-education towards the foundations of Computer Science and Computational Science and Engineering.
- Reading The Art of Computer Programming, part 1 to 4A (Donald Knuth)
#### What I've been working on lately (most notably):
- Delving deeper into Go/Golang development for enterprise applications
- Specialization towards software architecture/design and SRE
- Reading books related to productivity, management and soft-skills
- Reading books related to process/management with software development
- Reading Start With Why and similar books on startup branding and mission definition
- Delving deeper into compiler engineering and assembly language(s)
- Kubernetes in production
##### What I want to learn (most notably):
- Getting more acquainted with the purely functional programming languages Haskell and Elm
- Systems programming with Rust
- Embedded programming with C, C++ and/or Rust
- Become familiar with Erlang, OCaml, C++, Dart, F# and Smalltalk for research purposes
- Functional front-end web development with Elm, ReasonML and/or ClosureScript
- WebAssembly, icm. Rust, Go and/or C
- Artificial Intelligence and Neural Networks with Tensorflow
+22
View File
@@ -0,0 +1,22 @@
+++
date = "2020-07-18"
title = "Hello, world!"
slug = "hello-world"
tags = [""]
categories = ["Updates"]
[params]
metadescription = 'Hello world!'
metakeywords = 'luciano nooijen, blog'
+++
## Welcome
Big thanks for checking out my personal blog!
As of today, my website is completely in English and I've also added [my learning page](/learning) on here, where I tell about my learning path to try and become a self-taught computer scientist.
With this, I've also added this blog on my website, where I'll be posting from time to time about my experiences in learning, or to share information I hope will be valuable to others.
I'm also thinking about giving my site an overhaul, to improve the styling, readability and responsiveness and to add some features, but until this website is booming with traffic, I think this site will be more than just fine.
Anyway, that's it for now!
@@ -0,0 +1,46 @@
+++
date = "2020-07-28"
title = "How the corona pandemic might permanently change our work-attitude"
slug = "lasting-effects-corona-pandemic-on-work-attitude"
tags = ["Covid19", "Work from home"]
categories = ["Society"]
[params]
metadescription = 'Lasting effects of the corona pandemic'
metakeywords = 'covid, covid19, work from home'
+++
_This article is a translated and slightly modified version of the article I originally wrote in Dutch for Bytecode. To read the original, click [here](https://web.archive.org/web/20210303031257/https://bytecode.nl/insights/coronacrisis-positief-effect/)_.
Whether we like it or not, the corona pandemic is far from over. Even though quite some countries are slowly going back to "normal" (albeit with some more distance between each other), others have yet to reach the peak of their first wave.
We should not forget that, for most people, it will take a while to go back to the large office buildings, nearly hugging each other in the elevator while going to the meeting with everyone in person, cramming 8 people into a tiny room. Should we actually go back to this?
## Working before corona times
If you think about it, the current mindset about work is pretty crazy. Why do I have to be in the office to do work that I might be able to do a lot more efficiently in other places? Why do I still have to go to the lecture hall, where I can't skip parts I already know and where I can't rewind if I want to hear something again (without delaying the lecture for others)?
Before the corona crisis, few people were lucky enough to have the opportunity to decide their own approach to work. Mostly, remote work is only allowed when ill. Starting earlier in the morning, so that you can finish earlier in the day? No chance of success. Starting later and finishing later because you're not a morning person? Certainly not.
![lecture hall](/img/0001-1.jpg)
## Our mindset about working can be improved
Is it really necessary to work in this "pre-corona" way? Do you have to be in the office for everything? Of course not everyone can (or wants to) be a [digital nomad](https://en.wikipedia.org/wiki/Digital_nomad), flying across the world to work online in hotels. However, there are possibilities to make work more enjoyable for everyone. I don't envision a shelf filler, car mechanic or electrician who works from home at times that suit him. But a translator, consultant or marketeer?
Personally, I am very attracted to the ideas from the [async manifesto](http://asyncmanifesto.org/), a set of insights on how software development can be better implemented. The ideas come down to this: use modern tools, create a flexible work environment, do not disturb people's concentration unless it's urgent and only hold meetings when it's really necessary. The async manifesto is focused primarily on software developers, but I think anyone with a non-physical profession can benefit from the tips given here.
When it comes to productivity, it seems that working from home (in a quiet office) is a lot [more productive](https://www.inc.com/marcel-schwantes/new-study-reveals-why-working-from-home-makes-workers-more-productive.html) than working at the office. It is fairly easy to understand why this is the case for many people. Every time you are interrupted in a "[deep-work](https://www.nrc.nl/nieuws/2016/03/30/de-superkracht-van-de-21ste-eeuw-1603307-a382406)" session, it takes [25 minutes](http://blog.idonethis.com/distractions-at-work/) to get your focus back. Not to mention the "this-could-have-been-an-email"-meetings that take an hour each.
How great would it be, instead of being constantly interrupted for some trivial question, to just answer non-urgent questions a few times per day? To not be disturbed by people who talk too loudly about yesterday's soccer game? As Jason Fried explains in his [TED talk](https://www.ted.com/talks/jason_fried_why_work_doesn_t_happen_at_work), you have to be able to work long uninterrupted stretches to really get something done.
![cat on keyboard tired of hearing corporate office jargon](/img/0001-2.jpg)
## The corona pandemic offers an opportunity to reflect on the current way of thinking
Now that a large part of us is still working from home due to the corona crisis, we have a chance to see what really works for us. Is that 9-to-5 job really ideal, or does a 10-to-6 or 7-to-3 job fit better, perhaps? Or why not 7-to-4 with an hour's break to run a lap? And if 9-to-5 office-based work turns out to suit you best, that's fine too, of course!
How am I actually most productive? And more importantly, how do I actually get the most satisfaction from my work? These are questions to which we will be able to find an answer in the near future, because we now have room for experimentation.
Hopefully the changes in (higher) education will also have a lasting effect, because of the long-awaited technological innovation.
I'm not in favour of closing down all the offices and changing to remote work as a standard, which is naturally unfeasible. For example, I also think that face-to-face meetings work better than talking on the phone. But I believe that - with companies large and small - a lot can be improved compared to the current situation, if the right balance can be found. Let's hope that this will be a positive lasting effect of this corona crisis.
@@ -0,0 +1,105 @@
+++
date = "2020-08-24"
title = "Runtime JSON typechecks with Typescript interfaces"
slug = "runtime-json-typechecks-with-typescript-interfaces"
tags = ["Typescript", "Code Generation"]
categories = ["Frontend"]
[params]
metadescription = 'How to add runtime typechecks for JSON in Typescript'
metakeywords = 'typescript, json, typechecking'
+++
_The contents of this article are technical in nature and mainly written for software developers_
Bytecode is currently working on a project for a startup. We are developing an app that is linked to a backend data storage service. For this project, I wanted to have more security around the API calls and validate the types at runtime.
## Background
At Bytecode, we use Typescript extensively for front-end and mobile development, to prevent Javascript's liberal dynamic typing system from causing errors. Typescript adds an extra layer of security. In recent years, we have seen a strong decrease in type-related errors due to the use of Typescript.
However, Typescript also has it's limitations, the main one being that types and interfaces disappear during compile-time. So there are no run-time checks on external data that is not available during compilation, like for example API responses. Ideally, Typescript would support marshalling in a way [similar to Go](https://medium.com/rungo/working-with-json-in-go-7e3a37c5a07b), to secure type safety during runtime. Unfortunately, however, the Typescript layer disappears during compile-time and only Javascript remains: code that knows nothing about the types and interfaces defined in the source code. Unfortunately, the Go-like approach would never be possible using features built into the language.
Until recently, we did our API calls directly within Redux actions, which didn't cause any problems for small applications. However, this setup is not the best, considering the [single-responsibility principle](https://en.wikipedia.org/wiki/Single-responsibility_principle) and keeping in mind that Redux actions can get confusing with large projects, if you're not careful. This is why we recently switched to creating separate API packages as an abstraction layer on top of the API calls. We now only call a function that executes the API calls and checks HTTP errors. If there are no errors, we'll get the data back. If there are errors, an error is thrown. This way, the Redux code does not know anything about the details of the API call.
Another reason to use a separate API package, is our desire to be able to set up an SDK more easily later on, when we start targeting another platform (think of an application, first only web, but where a mobile app will be added as well). It will then be possible to use shared code easily, without duplicate logic. Since these API packages become increasingly important with more dependent applications, the run-time guarantees also get more crucial. If the SDK says that a function returns a certain data type, we also want to guarantee this or else give an error message.
## Requirements and research
Our research question consisted of several parts:
- How can we generically check a JSON object against a Typescript interface, without the need for duplicate code for type definitions?
- How can we achieve the above without having to modify other production code outside the API package to make this check possible?
- How can this be done in NodeJS, React Native and in the browser?
There are enough libraries that make it possible to check a JSON structure, based on a DSL (domain specific language). However, this wasn't what we were looking for, because we were already using Typescript and didn't want to maintain the same type definition in multiple ways. We would prefer to develop a solution where no code generation or extra step in compilation is needed, but everything on-the-fly during runtime (like Go).
A few months ago, I read a [blog post by Picnic](https://blog.picnic.nl/guarding-a-react-native-application-from-evil-json-6f7cbb4404de), describing their project "Aegis", in which they had offered a solution to this problem. However, I noticed that it was still difficult to implement. The code is open source, but there was no example of implementation on a larger scale, because this was done within Picnic's proprietary app. A code generation step was also needed. We would, if possible, prefer not to have this extra step.
On the Subreddit of Typescript I had placed a [post](https://www.reddit.com/r/typescript/comments/i8yk6i/validating_objects_type_at_runtime/), where I submitted my question. I primarily received responses with examples of code generation solutions. Some responses discussed runtime solutions, but these solutions were unnecessarily complex and/or required modifications within the build configuration of Typescript. We prefer to avoid this, because we prefer to keep something experimental separate from the rest of our production code, so that if we are not satisfied, we can revert the changes.
A possible solution that popped into my head was the following:
- Load all type-definitions through the file system as strings
- Use the Typescript compiler as production dependency and parse these strings
- Compare the result of parsing against the JSON data to see if it matches the interfaces
However, this would mean that a substantial part of the Typescript compiler would have to become part of the app and thus increase the bundle size. The Typescript compiler is not the fastest in the world either, so this would take a considerable amount of extra time when it has to be done on-the-fly. In addition, the filesystem is only suitable for Node.js and not for browser environments, so compatibility could not be maintained. Unfortunately this solution was not feasible.
Ultimately, I chose to use [Picnic's Aegis](https://github.com/PicnicSupermarket/aegis), mainly because of the simplicity of the tool and because it can be used without modifying other aspects of the project (compilation steps, configurations or production code in other parts of the application).
## Implementation
The final implementation is as follows. Within the API folder, all public types (that is, the arguments and return types of the entire API package) are defined in the `types` folder. For all types in this folder, Aegis creates decoders and stores them in the `internal` folder of the API package.
In order to make the aforementioned solution usable for Bytecode, a few adjustments had to be made in Aegis. For example, we added ESLint comments at the top of the file. This was done in a [fork of Aegis](https://github.com/lucianonooijen/aegis/tree/bytecode) on Github. This is the dependency used in Bytecode's project. To build the decoders, a command has been added to the `package.json` of the React-Native/Expo project. By simply running `yarn run aegis`, Aegis is called with the right arguments and all decoders are built.
The production code of the API package already used an internal `returnOrThrow` function, which received an internal API response type (consisting of the response of the API and/or an error if it occurred), threw an error if it existed and otherwise returned the data. This function has been modified so that a second argument is given to the function, namely the decoder. In `returnOrThrow` the decoder is then used to check the data before it is returned. See the example below:
```ts
import { Decoder } from "decoders/types";
import { guard } from "decoders";
interface APIResultSuccess<T> {
data: T;
error?: undefined;
}
interface APIResultFailure {
data?: undefined;
error: string;
}
type APIResult<T> = APIResultSuccess<T> | APIResultFailure;
const throwOrReturn = <T>(result: APIResult<T>, decoder: Decoder<T>): T => {
if (result.error) {
throw new Error(result.error);
}
// We can assume that data is valid (type T) if no error was found
const data = result.data as T;
const decodeChecker = guard(decoder);
const _ = decodeChecker(data); // Throws if it's not valid
return data;
};
export default throwOrReturn;
```
If the return body does not satisfy the decoder, an error is thrown, which can be caught when calling the API call.
## Wishlist
For now, the implementation of JSON type checks is still experimental. Within the codebase, this type check only affects a small (separate) part, so it can be easily removed later on. This is why it is not automated yet, something we would like to add later on. Automating this without modifying the build configuration can still be a challenge. Until we automate this completely, we can add a check in the CI pipeline that gives an error message when running Aegis causes file changes in Git, meaning the decoders have not been updated after making changes to the type definitions.
Another very good use-case of this setup would be the end-to-end testing of the API for which the package is built. The end-to-end testing of APIs is something that has been on the Bytecode wishlist for some time now. JSON type checking can also provide great added value, allowing you to immediately check whether the API returns data according to the expectations.
Regarding improvement to Aegis itself, at the moment Aegis is mainly built for the "happy flow", there are still some edge cases that don't quite work. The tool is now definitely usable, but improvements are still needed for large-scale use.
Support for Aegis configuration files is another useful addition. Now arguments for `importPath` and `outputFile` must be given as CLI options. Simply calling `aegis generate`, which then loads the configuration by itself would be a nice addition.
If it turns out that this workflow works very well for Bytecode, there is a good chance that we will further develop the Aegis tool ourselves, open-source of course. For now, the tool is not yet available on NPM. That would be the first step towards a stable release.
## Example project
As an addition to this article, an example project is also available, with a simple API call, where the tool can be seen in action.
Check out the [project on Github](https://github.com/lucianonooijen/ts-runtime-json-checks-example).
@@ -0,0 +1,16 @@
+++
date = "2022-01-13"
title = "Webinar replay: common pitfalls for software start-ups and how to avoid them"
slug = "webinar-replay-software-start-up-pitfalls"
tags = ["Webinar", "Start-ups", "Video"]
categories = []
[params]
metadescription = 'Webinar replay for start-up pitfalls'
metakeywords = 'start-ups, pitfalls, webinar, luciano nooijen'
+++
On November 29th, 2021, I gave a webinar for Bytecode about common pitfalls for software start-ups and how to avoid them.
A replay of this webinar can be seen here:
<iframe width="560" height="315" src="https://www.youtube.com/embed/Yl2wDvipw38" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
+18
View File
@@ -0,0 +1,18 @@
+++
date = "2025-02-12"
title = "Hello, world! Again!"
slug = "hello-world-again"
tags = [""]
categories = ["Updates"]
[params]
metadescription = 'A short update on this blog'
metakeywords = 'luciano nooijen, blog'
+++
Recently, I have had quite a lot of ideas about interesting blog posts, some short, some long; some general notes and some more technical. All in the hope that this can be helpful or at least somewhat insightful for others.
I have updated my website to add slightly better blog support, including categories and tags to make this possible, plus better RSS feed support.
Currently, the website is quite basic. And although I have considered giving it a bit of an overhaul with better styling, I would prefer to keep this website somewhat minimalist. I might make some changes to improve readability, but the styling itself will still be very minimalistic.
Anyway, that's it for now!
+35
View File
@@ -0,0 +1,35 @@
+++
date = "2025-02-13"
title = "Easy help command for Makefiles"
slug = "help-makefile"
tags = ["QOL"]
categories = ["Tools"]
[params]
metadescription = 'Adding a help command to your Makefile'
metakeywords = 'makefile, help command'
+++
I love Makefiles, they are my prefered way to manage builds. Not just for C or C++, I also use it for Golang for example. To me, it is the best way to manage build commands in a single place.
As I love CLI tools in general, and working with them, the `--help` flag is often a great help, but Make does not offer this out of the box, and navigating complex Makefiles can be challenging.
I can't remember where or when, but a few years ago, I found a snippet somewhere of a Makefile command that adds a coloured text output of all Makefile commands. Using it is as simple as making the following your first Makefile command:
```Makefile
.PHONY: help
help: ## Shows all commands
@echo 'All Makefile commands:'
@grep -h -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
```
After adding this, you can simply add comments after your other Makefile commands with two `#` characters, like this:
```Makefile
.PHONY: dev
dev: CFLAGS = $(CFLAGS_DEV) ## Development build for /src and /example
dev: clean $(LIB) $(EXAMPLE)
```
This will now be automatically generate help output when running `make help`, or when you have defined this as your first Make command, with simply `make`
![make help output](/img/0005-1.jpg)
+334
View File
@@ -0,0 +1,334 @@
+++
date = "2025-02-14"
title = "My favourite way to handle SQL in Golang"
slug = "best-golang-sql-handling"
tags = ["Golang", "SQL"]
categories = ["Backend"]
[params]
metadescription = 'How to best handle SQL logic in Golang applications for migrations and queries'
metakeywords = 'golang, sqlc, go, sql, migrations, code generation'
+++
_tl;dr: using sqlc and golang-migrate allows plain easy SQL in your application_
Golang is a great language when building web/API services. And that makes a lot of sense, as Go was created for building scalable, performant applications. The language is very cohesive, compiles fast, has great performance and has a great ecosystem in addition to its rich standard library and great toolchain. Whenever I need to build a web service or API, I tend to reach for Go when possible, hence why I built quite a lot of applications with it so far, including around a dozen or so application back-ends.
But this is not an article about why Go is great, but rather on how to handle SQL. Considering Golang is not made to be used in Ruby on Rails-type frameworks, you'll most likely have to set up the SQL-logic yourself.
My approach to SQL handling in Golang is the result of trying out a lot of approaches and seeing what works and what doesn't. This approach is mostly aimed at building API services, backed by a database. In my case, I use Postgresql, though other SQL databases should also work with a few tweaks.
As most applications that I build deal with quite a lot of data, I don't want to work with ORMs like Gorm, due to the performance impact and unnecessary abstractions that it introduces. In general, I'm not the biggest fan of ORMs, as I already know SQL and don't like learning additional tools to abstract away the actual SQL code, using SQL directly for me is simply faster, both in terms of performance as well as writing the application.
So how then do I approach SQL in Golang?
## Code generation with sqlc
One of the things I like about the Go ecosystem is that there are a lot of code generation tools. Code generation is something that is quite underused in my opinion, and [sqlc](https://sqlc.dev/) is an amazing example of how code generation can be very powerful.
With sqlc, you can write plain sql, and then run a command that generates the Go code in a module you can use in your application.
## Configuring sqlc
After [installing sqlc](https://docs.sqlc.dev/en/latest/overview/install.html) on your device, getting up and running with sqlc is quite trivial. The generic installation instructions are found in the [sqlc docs](https://docs.sqlc.dev/en/latest/tutorials/getting-started-postgresql.html), but I'm using a bit of a modified configuration.
We start with creating the `sql/` and `migrations/` folders, for SQL queries and database migrations.
In the migrations directory, add a file `0000_schema.up.sql` and `0000_schema.down.sql` for adding your initial database schema, take this up migration for example:
```sql
BEGIN;
-- Enable uuid extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Users table
CREATE TABLE users (
-- User fields
);
-- The rest of your schema
COMMIT;
```
Make sure to add the down migrations as well. The migrations are used by sqlc to generate the database structure and add typed columns for database manipulations. They will also be used to perform actual database migrations. Wrap your migration logic in `BEGIN` and `COMMIT` so you can `ROLLBACK` on a failed migration.
## Full sqlc config example
Here is the full configuration I'm using for the Capsa API service:
```yaml
version: "2"
sql:
- engine: "postgresql"
queries: "sql/"
schema: "migrations/"
gen:
go:
package: "database"
out: "internal/data/database"
sql_package: "pgx/v5"
emit_json_tags: true
json_tags_case_style: "camel"
emit_pointers_for_null_types: true
overrides:
# UUID
- db_type: "uuid"
go_type:
import: "github.com/google/uuid"
type: "UUID"
- db_type: "uuid"
nullable: true
go_type:
import: "github.com/google/uuid"
type: "UUID"
pointer: true
# Timestamp
- db_type: "pg_catalog.timestamp"
nullable: true
go_type:
import: "time"
type: "Time"
pointer: true
- db_type: "pg_catalog.timestamp"
go_type: "time.Time"
# Custom
- column: "logs_chunks.category_counts"
go_type:
import: "github.com/capsa-gg/capsa/server/internal/entities"
type: "LogChunkMetadata"
- column: "logs_chunks.severity_counts"
go_type:
import: "github.com/capsa-gg/capsa/server/internal/entities"
type: "LogChunkMetadata"
```
This configuration replaces the pgx (Postgres database driver) UUID and Timestamps with the Google and standard library implementations respectively. It also specifies a few column types to be used, so the returned data has better compatibility with my domain logic. For further explanation on this, see the [docs](https://docs.sqlc.dev/en/latest/reference/config.html).
## Writing SQL statements
With sqlc configured, we can write some SQL code! The [docs](https://docs.sqlc.dev/en/latest/howto/select.html) explain things in more detail, but here are two very simple examples:
```sql
-- name: GetUserByID :one
SELECT * FROM users
WHERE id = $1;
```
Gets a user by their ID. The `*` return value here will use the database schema to determine the return type to make it fully typesafe.
```sql
-- name: UpdateUser :one
-- Update a user with optional parameters
UPDATE users
SET first_name = @first_name,
last_name = @last_name,
user_role = @user_role
WHERE id = $1
RETURNING *;
```
For updating a user, this also shows how arguments can be named with sqlc for clarity in the domain logic.
## Complex SQL
The two examples above are the 'hello world' equivalent of SQL statements. In larger applications things become more complex. An example of this is searching. Although there might be better ways, this is the way I have implemented search for Capsa, which is not too complex:
```sql
-- name: SearchResources :many
-- Searches in some database tables to find matching resources based on a "contains string" pattern (LIKE '%<arg>%')
WITH resources AS (
SELECT
'Environment' AS table_name,
lower(e.key::text) AS identifier,
concat(e.name, ' environment for ', t.name) AS description,
'' AS details
FROM environments e
LEFT JOIN titles t ON t.id = e.title
UNION ALL
SELECT
'Logs' ,
lower(l.log_uuid::text) ,
concat(l.log_type, ' log on ', platform, ' (', t.name, ', ', e.name, ')'),
count(lc)::text
FROM logs l
LEFT JOIN environments e ON e.id = l.environment
LEFT JOIN titles t on t.id = e.title
LEFT JOIN logs_chunks lc ON l.id = lc.log
GROUP BY l.log_uuid, l.log_type, platform, t.name, e.name
)
SELECT table_name, identifier, description::text, details::text
FROM resources
WHERE identifier LIKE '%' || lower(@search) || '%'
LIMIT sqlc.arg('limit');
```
Another more complex example is filtering with optional arguments. The full statement is over 50 lines long, but here is the most important part of the implementation for Capsa:
```sql
-- name: ListAvailableLogs :many
-- Fetches all log chunks and aggregates an overview.
-- LogUUID is an optional field used as a filter, which if set will return only a single result.
WITH -- Omitted
SELECT -- Omitted
FROM logs l
JOIN cat_counts cc ON cc.log = l.id
JOIN sev_counts sc ON sc.log = l.id
JOIN chunk_data cd ON cd.log = l.id
JOIN environments e on l.environment = e.id
JOIN titles t on e.title = t.id
LEFT JOIN links ll on l.id = ll.source
WHERE ( l.log_uuid = sqlc.narg(filter_by_log_uuid) OR sqlc.narg(filter_by_log_uuid) IS NULL ) -- Optionally filter by Log UUID
AND ( e.key = sqlc.narg(filter_by_environment)::uuid OR sqlc.narg(filter_by_environment) IS NULL ) -- Optionally filter by Environment
AND ( l.platform = sqlc.narg(filter_by_platform)::varchar OR sqlc.narg(filter_by_platform) IS NULL ) -- Optionally filter by Platform
AND ( l.log_type = sqlc.narg(filter_by_logtype) OR sqlc.narg(filter_by_logtype) IS NULL ) -- Optionally filter by LogType
GROUP BY l.id, t.name, e.name, cd.line_count, cd.chunk_count, cd.earliest_start, cd.latest_end, ll.sum
ORDER BY earliest DESC
LIMIT @fetchlimit::int;
```
This will simply ignore all arguments that are not set, or include them if they are. In all honesty: the syntax with an ORM would be cleaner, but I do suspect the performance will take a hit. If performance with `ListAvailableLogs` becomes an issue, I can optimize it, with ORMs, that is a lot harder.
## Generating the code
To generate the code, simply run `sqlc generate` in the root of your project, and you should have your code! There are more commands you can use for analysis and linting, which are outlined in the [docs](https://docs.sqlc.dev/en/latest/howto/generate.html).
## Using the generated code
During the application start, you can use your config to generate the instance of the sql-generated structs:
```go
package example
import (
"github.com/jackc/pgx/v5/pgxpool"
"github.com/capsa-gg/capsa/server/internal/data/database"
)
// NewDatabase initializes a Database instance.
func NewDatabase(c *entities.Config) (*database.Queries, error) {
ctx := context.Background()
// Database connection
dbConn, err := pgxpool.New(ctx, c.DatabaseConnectionString())
if err != nil {
return nil, fmt.Errorf("error opening database connection: %w", err)
}
// Ping database
err = dbConn.Ping(ctx)
if err != nil {
return nil, fmt.Errorf("error pinging database: %w", err)
}
// Database instance
db := database.New(dbConn)
return db, nil
}
```
You now have a `*database.Queries` instance that you can use to use query the database! Using it is as simple as
```go
user, err := db.GetUserByID(ctx, userId)
```
There is also support for performing logic inside of a transaction, as outlined in the [docs](https://docs.sqlc.dev/en/latest/howto/transactions.html#using-transactions).
## Handling migrations
You should only be changing the database structure using migrations. We already have the migration scripts used by sqlc, which we can use to perform the database migrations.
For migrations, I'm using the golang-migrate package, with a small wrapper. This is the full code of the migrator package for Capsa:
```go
package migrator
import (
"database/sql"
"errors"
"fmt"
"net/http"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/pgx/v5" //nolint:gocritic,stylecheck // Needs import for using pgx.WithInstance
"github.com/golang-migrate/migrate/v4/source/httpfs"
// Needs side effect from pgx/v5.
_ "github.com/golang-migrate/migrate/v4/database/pgx/v5" //nolint:gocritic,stylecheck // Needs import for side effect
"github.com/capsa-gg/capsa/server/migrations"
)
// Direction indicates the migration direction.
type Direction string
// UpAll and DownAll indicate the migration direction.
const (
UpAll = Direction("UpAll")
DownAll = Direction("DownAll")
)
// New returns a migrator closure that will accept UpAll or DownAll to up or down migrate the database.
func New(dbConn *sql.DB, dbName string) func(Direction) error {
return func(direction Direction) error {
// Direction check
if direction != UpAll && direction != DownAll {
return errors.New("migration direction should be 'UpAll' or 'DownAll'")
}
driver, err := pgx.WithInstance(dbConn, &pgx.Config{})
if err != nil {
return fmt.Errorf("invalid target postgres instance, %w", err)
}
// Source instance for the migrations embedded in server binary
sourceInstance, err := httpfs.New(http.FS(migrations.Migrations), ".")
if err != nil {
return fmt.Errorf("invalid source instance, %w", err)
}
// Create migrator instance
migrator, err := migrate.NewWithInstance("httpfs", sourceInstance, dbName, driver)
if err != nil {
return fmt.Errorf("failed to initialize migrate instance, %w", err)
}
// Do the actual migration
if direction == UpAll {
return handleMigratorErrors(migrator.Up())
}
if direction == DownAll {
return handleMigratorErrors(migrator.Down())
}
return errors.New("you should not see this")
}
}
func handleMigratorErrors(err error) error {
if errors.Is(err, migrate.ErrNoChange) { // Do not report error when no database change
return nil
}
return err
}
```
With this, performing the database migrations is as simple as
```go
import "path/to/migrator"
migrate := migrator.New(db, config.DatabaseName)
err = migrate(migrationDirection)
```
Now you have good 'ol plain SQL for migrations as well as database queries in Golang with code generation!
## Example to see this in action
An example of this approach in action can be found in the [Capsa API server](https://github.com/capsa-gg/capsa/blob/main/server/). Capsa is still work in progress, but I highly doubt the SQL approach will change.
@@ -0,0 +1,47 @@
+++
date = "2025-02-15"
title = "Finding the best translation for Marcus Aurelius' Meditations"
slug = "best-translation-meditations"
tags = ["Meditations", "Marcus Aurelius", "Stoicism"]
categories = ["Books", "Philosophy"]
[params]
metadescription = "After reading around 10 different translations of Marcus Aurelius' Meditations, which translation do I like best"
metakeywords = 'meditations, translation, marcus aurelius, waterfield'
+++
I have read quite a lot of book on Greek and Roman philosophy and Marcus Aurelius' Meditations is the book I have read most often by far. In total, I own around 7 translations and I have read sections of other translations as well. In addition to this, I have read several secondary works, in addition to the sections of the Meditations that I have read in the original Greek. In 2021/2022, I have hosted an eight-month long weekly reading group on the Meditations as well.
One important note about reading philosophical works, is that translation is a very important aspect if it was written in a different language. There are many features present in Ancient Greek (the language that Meditations was written in) that are not present in English, or words with no English equivalent.
Translation is not just about translating the words but - especially with philosophy - it is also important to capture the style, tone, etc. of a text as this is essential to communicate the meaning of the source material. If that is not possible due to linguistic challenges, that should be clarified by the translator.
So, back to Marcus Aurelius. After reading the Meditation and several secondary sources, what is my favourite translation of the work in English?
## Waterfield's Annotated Edition
By far the best translation of the Meditations in English is _Robin Waterfield's Annotated Edition_.
In this translation, Waterfield does an amazing job of capturing the conciseness of the original work. The English text 'feels' very similar to the original, literary, yet direct.
In addition to the great translation, this edition has a great introduction, clarification on translating certain terminology of Greek philosophy, but to me the best part are the tons of annotations present as footnotes.
The Meditations was not a book to be published, for Marcus Aurelius, it was most likely his "commonplace book" and Waterfield does an amazing job of adding connections to other works works and even to similar passages within the book, or simply explaining cultural references.
If you are interested in reading Meditations, or if you have read it before but want to understand it better, you cannot go wrong with this edition.
The ISBNs are:
- Hardcover: 9781541673854
- Paperback: 9781541673861
## Other translations worth checking out
- _If you can read Ancient Greek_: Haine's translation from the Loeb Classical Library is not an amazing translation, but has the original text on the left, and the English translation side-by-side on the right, which can be great.
- _Dutch translation_: I have not read as many Dutch translations as English ones, but I like Simone Mooij-Valk's "Persoonlijke notities" best so far.
- _German Translation_: there is a German translation of Waterfield's edition (Selbstbetrachtungen: Die kommentierte Edition, translated by Elisabeth Liebl), which I have partly read and quite liked.
<br>
> ἐξετάζειν τί μοί ἐστι νῦν ἐν τούτῳ τῷ μορίῳ, ὃ δὴ ἡγεμονικὸν καλοῦσι, καὶ τίνος ἄρα νῦν ἔχω ψυχήν;
>
> At this moment, what is occupying that part of me they call the command center? What kind of a soul do I actually have at the moment? (Waterfield's translation of Meditations 5.11)
+96
View File
@@ -0,0 +1,96 @@
+++
date = "2025-02-23"
title = "Why Baldur's Gate 3 is a masterpiece"
slug = "why-baldurs-gate-3-is-a-masterpiece"
tags = ["Review"]
categories = ["Games"]
[params]
metadescription = "Baldur's Gate 3 is the best game I have ever played and why it sets a new gold standard for RPGs (no spoilers)"
metakeywords = 'baldurs gate 3, bg3, masterpiece'
+++
_This article does not contain spoilers._
I should preface this by saying that I have never played Dungeons and Dragons in my life, and knew very little about it before playing Baldur's Gate 3. I started playing the game because it was recommended to me by my partner and I was somewhat skeptical about it, because it was so foreign to me. It was nothing like I expected, in the best way possible.
I am not a huge gamer. Even though I work on games, I tend to enjoy that more than actually playing games. I am quite picky with the games I play. There are quite a lot of games that I have enjoyed a ton, but nothing comes close to Baldur's Gate 3, it is by far the best game I have ever played.
My first playthrough took me around 80 hours, where I took my time but didn't do most side-quests (which I didn't even know existed). My partner's playthrough is, at the time of writing, 196 hours long and we're only now moving into the end game [^finished]. I have also started a few playthroughs to explore different classes and approaches to the game at higher difficulties. It will be a long time before I'm done playing this game.
[^finished]: After 214.5h we finished the game on March 23, 2025.
For brevity, I'll abbreviate Baldur's Gate 3 to BG3 going forward.
## Endless choice and replayability
Having choices is of course a core part of RPGs, but BG3 takes this to almost an extreme. You don't feel forced to take a particular path in your playthrough and there are no predefined paths you can take.
Choices you make early on can have a lot of influence on later parts of the game. The options you are given are not just to get a particular response from an NPC, but actually have a huge impact on the world in later acts.
No two playthroughs are the same. You can play as a murderous psychopath, a charming bard, or even as a cat. Even if you pick the infamous stealth archer min-max build, it's highly likely two playthroughs will be radically different, even when you follow an Honor Mode guide to complete the game.
Romancing characters is done incredibly well and offers a ton of choices as well. You cannot romance all characters at once, and romancing a different companion offers even more value to start another playthrough.
On the topic of romance, I feel obliged to mention the 'bear scene' (if you know, you know). The fact that Larian took the time to add this as an option to me is incredible, regardless of which choice you made if you encountered this, the fact it's there is already amazing to me.
## Cohesiveness of the story
It's incredible to me how cohesive the story is. With so many different ways to do missions, get somewhere or handle situations you would think there would be many inconsistencies later in the game.
Sure, there might be some tiny inconsistencies here and there, but to me it's amazing how a game where the story is not linear apart from the three Acts can make the story of any playthrough feel like it was written to be played this way.
The overarching story of BG3 has some of the best storytelling that I have ever seen in a game, even better than most movies or TV series. Everything comes together, there are many plot twists that all make sense.
Everything in BG3 is interconnected. There are no parts of the story or world that feel out-of-place. Random NPCs you encounter in Act 1 will often reappear later in the game (unless you kill them for fun).
Having a game with such freedom of choice with a better, more cohesive story than most TV series and movies is nothing short of amazing.
## Character development
The character development in BG3 is phenomenal, for companions as well as NPCs, both with phenomenal voice acting. Even side characters you meet when making certain choices in Act 1 will show up in later acts, and have gone through some development. The development of the dream visitor is amazing as well.
The villains in the game are so incredibly well-written. The villains are not just your everyday antagonists, they have complex personalities, there is character development for them during the game, tons of backstory and with some you can even work together if you like.
And that is such an amazing part of the game to me as well: there is such a degree of freedom to morality. Without getting too much into the political influence of modern games, I think BG3 does an amazing job of offering choices without forcing anything upon the player, which I think is the best approach, regardless of what your personal preferences are.
To expand a bit upon the complex personality of NPCs, one thing I dislike in some games is when all characters are super obviously 'good' or 'bad'. This is definitely not the case in BG3. Yes, there are some 'evil' characters, but even a literal devil in the game is presented in an almost 'good' way in some parts. This moral ambiguity adds another amazing layer to the story.
The companions deserve mentioning here too, they all have their own storyline with tons of choices, will (dis)approve of your choices and their personalities can change drastically based on decisions made. A part that I have not played myself, but only seen in playthrough videos is the fact that for many characters, you can play with them as the main character, unlocking more unique content.
## Attention to detail
The attention to detail in BG3 is absolutely insane, which shows in many ways. A great example is that there are some voice lines that you will only hear in a modded playthrough, as your character enters a scene in a state that isn't possible in the vanilla game. Even in the vanilla game there are tons of hidden details that most players will miss.
Larian added some details in the game that they knew less than one percent of players will ever see, some might still be undiscovered. This is such a stark contrast to some released games that feel unpolished, or some even unfinished, until a few months after release.
A game like BG3 obviously needs a lot of failsafes, think of cases where crucial NPCs get killed, locking you out of progressing a certain quest. The failsafes in BG3 are very subtle, they don't limit your freedom to kill certain NPCs and even the failsafes have failsafes. I have had a few nights where I binge-watched videos about the failsafes in BG3 and it's still incredible to me how many edge cases were covered.
The attention to detail is also clear with NPCs in the game. Nearly all NPCs have voice lines, most you can talk to, and quite a few completely random NPCs will offer some sort of side quest (that of course fit into the rest of the story really well). The banter between companions is another great touch as well.
## Post-launch patches
To me, there is a very big difference between post-launch patches to make the game playable versus patches that improve the game. I won't be naming any games, but there are some prominent examples of games that were released and needed a few patches (not counting the day-one patch) just to be playable. BG3 was not one of them.
Larian did release patches after launch, but these were mostly to improve the game further and take care of community feedback. Some amazing features have been added here, like Honor Mode and the epilogue scenes. In addition, some voice lines have been replaced, they were already fine, but the fact Larian took the time to improve further on this to me is great.
## Mod support
Unofficial mod support was already available, but in a post-release patch, Larian added native mod support. Right now, it's even possible to have cross-platform multiplayer sessions with mods enabled (of course with some platform restrictions). The mods add a ton of new content and possibilities, adding even more replayability to the game.
## Music
I'm a huge fan of the soundtrack of BG3. The music fits the game incredibly well. The song of the boss fight with one of my favourite characters of the game, Raphael, still gives me goosebumps when I hear it. Then there's also the fact that this character stars in his own boss song, which is amazing to me as well.
## Lack of microtransactions
One of the things BG3 does that I hope will be adopted by other games is the lack of microtransactions. There are no DLCs and no special currencies purchased with real money. You buy the game and that's it. All post-release content was made available to all players.
The lack of microtransactions is another way to see that BG3 was made to be an amazing game, not as a corporate cash-grab. I'm not saying BG3 could never be made with investors being involved, but the freedom Larian had regarding monetization by not having to report to investors cannot be ignored.
## Conclusion
Baldur's Gate 3 has set a new gold standard for RPGs. In my opinion, this is, so far, the closest we have ever gotten to a 'perfect' RPG. Whether you tend to play casual or are into min-maxing your builds at the highest difficulty, BG3 is a game worth playing, over and over.
BG3 sets an example that I hope many games - both RPGs and other genres - will follow. There are many conclusions you can draw from BG3's success about the games industry as a whole.
To me, it will be a game that I will keep playing for a long time. With no DnD experience there is a ton for me to discover in the base game already, and after that there will be tons more with the official and unofficial mods.
+107
View File
@@ -0,0 +1,107 @@
+++
date = "2025-02-24"
title = "Announcing Capsa, an Unreal Engine logging solution"
slug = "announcing-capsa"
tags = ["Capsa", "Tools", "Unreal Engine"]
categories = ["Tools", "Games"]
[params]
metadescription = "Capsa makes Unreal Engine logging a lot better, especially for those working on multiplayer"
metakeywords = 'capsa, unreal engine, logging'
+++
_tl;dr: Capsa makes Unreal Engine logging a lot better, especially for those working on multiplayer. Check it out [here](https://capsa.gg/blog/announcing-capsa)._
I'm very excited to share what I've been working on over the last few months by announcing the v0.1 release of Capsa.
## Introducing Capsa
_An open-source Unreal Engine logging solution._
Ever since I started working on multiplayer games, I was quite surprised how few tools were available for working effectively with Unreal Engine logs for multiplayer games. After working on various titles, Mark Jawdoszak and I started building a tool called Capsa to address this. We've made this fully open-source so it can be used by other teams facing similar challenges.
More in-depth reasons why we've built Capsa and licensing can be found in [this blog post on Capsa.gg](https://capsa.gg/blog/why-we-built-capsa/).
## About Capsa
Capsa makes this easy by automatically collecting logs and making them accessible in a structured, searchable, and shareable way.
Some of the features Capsa offers:
- Logs with syntax highlighting and sharable links
- Filter, search, and link logs
- Merged client-server logs
- Minimal setup, no game core or engine changes
- Cloud-native, so host anywhere
- Optimized for performance
- Open-source, web stack AGPL3.0 and UE plugin MIT
- Dark-mode support
Working on Capsa has been a blast and we've got tons of ideas of new features we want to add over the next few months.
To read the full announcement post, check out [Announcing Capsa](https://capsa.gg/blog/announcing-capsa).
## Building Capsa
Some notes on building Capsa and the tech we have used.
#### Extending Unreal Engine
We chose to extend Unreal Engine rather than replace functionality. We are hooking into `GLog` as an output device, so we can capture `UE_LOG` calls and we don't require game code changes with a new log system. We want to have a system that is easy to add to and easy to remove from games, and I think this is a great way to achieve that.
The received log lines are formatted according to our [Capsa log format](https://capsa.gg/docs/technical/protocol) so the server can parse it correctly. This way we don't change any default Unreal Engine log output, but we receive the correct format on the server
#### Golang server log parsing
The log is processed in O(1) complexity, reading one character at a time. A buffer is built with all line characters until encountering a `\n`. The line buffer is then used to extract metadata for that specific line. The metadata is stored in the database and the log chunks are stored in S3-compatible storage.
Due to the per-character algorithm for log chunk processing, it allows the server to be very performant with extracting the metadata. At the time of writing this, parsing a 100k line log chunk (which is much bigger than will be done in production) takes just around 60ms.
More details on log processing can be found on [Log processing](https://capsa.gg/docs/technical/log-processing).
#### Server-side merging and filtering
We had to make an important decision on doing log merging and filtering, whether we wanted to do this on the server or client. There are pros and cons for each, but we decided to do this server-side.
By doing this, we can re-use the existing log parsing code for incoming logs, get better performance, but it means that changing the filtering/merging settings requires reloading the full log from the server. We figured we needed server-side logic anyway, as large logs cannot be kept fully in the browser's memory, so at least for v0.1, all of the merging and filtering logic is handled server-side.
Because we store per-chunk metadata, we can ignore chunks that don't fit the filtering criteria as well, giving us a good performance boost. The benchmark for filtering lines from a 100.000-line chunk for streaming to a user (as is used for the admin panel) is around 70ms.
#### Golang server SQL
For handling database logic, I chose to go with sqlc and golang-migrate, as I have written in my [Golang SQL handling article](./best-golang-sql-handling). This has been a good decision so far, as I have full control over the SQL to optimize for performance. I have already done a few optimizations in the queries, which would not have been possible with ORMs.
#### JWT with private/public keys
Authentication is hand-rolled in the API server, so there is no reliance on external parties for this. JWTs are used for this. Instead of using a signing secret, a private/public keypair is used.
This allows the web panel to check the JWTs (stored as a cookie) if they are valid and have users reauthenticate if they are not.
All API requests are done from the browser directly to the API server to not unnecessarily increase server load for the web panel. Yet we still want to validate the JWTs on page load to make sure we don't perform API requests that will return in a `401` or `403` response.
#### Building Capsa cloud-native
Another concern when building Capsa was making it possible to deploy anywhere. This is why I have chosen to build the web stack with Golang and NextJS. Deploying these to any Linux or Windows system is very trivial. Both of the applications are available as Docker images and can be hosted anywhere by setting some environment variables.
#### Log processing web worker
The web panel uses a web worker to process incoming logs, to not block the main thread, which would make the website feel a lot slower due. This is achieved by writing a class to manage the web worker, which is then exposed in a React hook to work with the rest of the logic.
#### Runtime JSON validation
For the web panel, we have added runtime type validation by using Zod. The React code uses `useSWR` to call API endpoints as hooks. Instead of using API endpoints directly, we have added wrappers for requests, for example `const { trigger, isMutating } = useAddTitle();` to add a new game title. This hook internally calls the API endpoint with `useSWR`, validates the JSON it gets back with Zod, and sets an error if it's not valid.
By doing this, we can avoid runtime type exceptions.
## Continuing development
We have tons of ideas on how to improve or extend the current functionality or new features we can add. But before we add this, we want to make sure we are on the right path with Capsa.
Now v0.1 is released, we will be integrating this into a few Companion Group projects and gather feedback from there, as well as early-adopters. We will continue to fix bugs and work on existing and new features over time, to slowly work towards a v1.0 release.
For v1.0 we have a few feature ideas that would make Capsa even more powerful to improve the lives of developers working on Unreal Engine multiplayer games. We are working on a few proof-of-concepts for this, though these will take some time as we need to figure out details, as we don't want Capsa to take over game codebases, we want to keep Capsa non-intrusive.
## Want to become an early adopter?
If you are interested in becoming an early adopter, the details on getting started are in [Announcing Capsa](https://capsa.gg/blog/announcing-capsa). You can also reach out to me if you need any help, I'm more than happy to help you get started with Capsa.
All feedback - positive or negative - is very much welcome as well!
+116
View File
@@ -0,0 +1,116 @@
+++
date = "2025-04-01"
title = "Why I stopped using AI code editors"
slug = "why-i-stopped-using-ai-code-editors"
tags = ["AI"]
categories = ["Tools"]
[params]
metadescription = "In the past I used AI code editors for all of my programming, but I stopped using it and recommend others to consider this as well"
metakeywords = 'ai, code editors, llm, vibe coding'
+++
_TL;DR: I chose to make using AI a manual action, because I felt the slow loss of competence over time when I relied on it, and I recommend everyone to be cautious with making AI a key part of their workflow._
In late 2022, I used AI tools for the first time, even before the first version of ChatGPT. In 2023, I started using AI-based tools in my development workflow. Initially, I was super impressed with the capabilities of these LLMs. The fact that I could just copy and paste obscure compiler errors along with the C++ source code, and be told where the error is caused felt like magic.
Once GitHub Copilot started becoming more and more powerful, I started using it more and more. I used various other LLM integrations right in my editor. Using AI was part of my workflow.
In late 2024 I removed all LLM integrations from my code editors. I still use LLMs occasionally and I do think AI can be used in a way that is very beneficial for many programmers. So then why don't I use AI-powered code editing tools?
## Tesla FSD
From 2019 to 2021 I drove a Tesla. Though I would never make the same purchase again, not for political reasons, just because the cars are quite low quality, very overpriced and a hell to repair or maintain.
When I got my Tesla, I started using the Full Self-Driving (FSD) anytime I could. It felt great to just put the car on FSD on the highway and zone out a bit. Switching lanes was as simple as hitting the turn signal, and the car would switch lanes. Driving for me was just getting to the highway, turning on FSD, telling the car to switch lanes every now and then, and listen to music/podcasts while zoning out.
If you drive a car often, you'll know that when you're driving on the highway, everything sort of happens automatically. Keeping your car in the lane at the right speed becomes a passive action, it does not require the type of focus that for example reading a book requires, it's the type of focus that walking requires, it happens in the background of your mind.
In the period from 2019 to 2021 I exclusively drove my Tesla for longer rides. After 2021, I went back to driving regular cars and making this switch was definitely not what I expected. Driving on the highway required my full attention for the first month or so, I had to re-learn keeping the car in the middle of the lane without thinking about it.
Being reliant on Tesla's FSD took away my own ability to go into autopilot.
## My experience with AI code editors
Working with AI-powered code editors was somewhat similar. Initially, I felt that I completed work a lot faster when assisted by AI. The work I was doing most of the time was not super complex, and AI felt like putting my Tesla on FSD, I could just guide the machine to do my work for me.
In my free time, I started working on a side project on my personal account on my work device. On this account, I did not have access to Copilot and my other cool, fancy AI tools. This is when using AI started to feel very similar to my Tesla FSD story.
I felt less competent at doing what was quite basic software development than a year or so before. All of a sudden, it made it very clear to me how reliant I had become on AI tools. Anytime I defined a function, I paused in my editor to wait until the AI tools would write the implementation for me. It took some effort to remember what the syntax was to write unit tests by hand.
With my work, AI started to become less useful over time as well. Not only did it take out the fun for me, but I started to feel a bit insecure about making some implementation decisions myself. Outsourcing the decisions to the AI seemed a lot easier. But sometimes, the AI couldn't figure things out, even with the best prompts. It was quite clear that because I did not practice the basics often, I was less capable with the harder parts as well.
## The loss of Fingerspitzengefühl
> **_Fingerspitzengefühl_** \[ˈfɪŋɐˌʃpɪtsənɡəˌfyːl\] is a German term, literally meaning _"finger tips feeling"_ and meaning intuitive flair or instinct, which has been adopted by the English language as a loanword. It describes a great situational awareness, and the ability to respond most appropriately and tactfully. [^fsg]
[^fsg]: Source: [Wikipedia](https://en.wikipedia.org/wiki/Fingerspitzengef%C3%BChl)
Defining seniority is a very tough thing. Though in my opinion a lot of being a "senior" is in soft-skills, when it comes to the technical hard-skills, a lot comes down to Fingerspitzengefühl. The longer you work with a language, framework or codebase, the more you develop this kind of intuition of what the correct approach is. The gut feeling of "something feels off" slowly turns into a feeling of "this is what we should do".
This developed intuition is not just on an architectural level. A big component is in the lower level details, when to use pointers (or what type of pointers), whether to use asserts or checks, what to pick from the standard library when multiple options are available (though senior C++ programmers still can't seem to agree on this).
This intuition is what I was slowly losing when relying on AI tools a lot. And this is coming from a lead developer. When I see a lot of hype about vibe coding, I can't help but think: how do you exactly expect to vibe code your way to senior? Where will you get the skills from to maintain and extend the vibe-coded codebase when the AI tools are down, or have become too expensive?
Even with larger context windows, more computing power, reasoning models or agents, there will be things that AI won't be able to do. Over time, the AI tools will be more and more powerful, sure. But when you receive a Slack message that "the website works fine, but the app is down in production; I tried it locally and there it works fine, nothing in Sentry either", good luck getting an AI agent to fix this for you. Maybe it can, maybe it can't. And when an AI agent can't figure it out, will your reply be "sorry, Cursor doesn't get it, will prompt more tomorrow"?
## You can get by without these tools
Sometimes it feels like you have to use AI or be out of a job in 6 months. We've been hearing the "3-6 months from now"-story for over two years at this point. I stopped trusting CEO promises about functionality "3-6 months from now" years ago. When I got my Tesla in 2019, I paid €6400 for functionality that was supposed to arrive in "3-6 months from now", and the functionality is still not present the way it was promised over 5 years ago.
Right now, it is unlikely that letting AI do your coding will work for projects larger than a university project. When working on legacy systems or larger projects in enterprises or when you need to work with and consult a lot of dependency internals (like I do with Unreal Engine), AI tools will often not be able to make things work. When you need to work with internal DSLs, tools or frameworks, good luck getting LLMs to generate useful output. For some industries, you can't even use AI tools at all for a multitude of reasons.
For some things you really should not _want_ to rely on AI. When implementing authentication systems like JWT[^jwt] signing or RBAC[^rbac], adding "and it should be secure" to the prompt won't make it secure if it's been trained on GitHub code that had CVEs[^cve]. When it comes to security, you should be the person who is responsible and understands this fully. Critical systems should be written and reviewed by humans, if we are heading to a situation where one AI agent writes the code, another reviews the autogenerated PR and then another AI agent deploys the code, we will see a huge spike of security issues soon.
[^jwt]: JSON Web Tokens, or JWTs are a common way to generate authentication tokens, among other uses
[^rbac]: Role-based access control (RBAC) is a mechanism to restrict system access by setting permissions and privileges
[^cve]: Common Vulnerabilities and Exposures (CVE) is a program used to identify, define and catalog publicly disclosed cybersecurity vulnerabilities, [cve.org](https://www.cve.org/)
## Where I draw the line
I still use AI, sometimes. I think it can be a great tool, when used wisely. I draw the line at integration. I keep AI fully separate from my code editor. All of the context, I add manually. I intentionally keep the effort required quite high, so it disincentivizes me.
Examples where I use AI for work include "convert these Go tests in structs to tests in a map", "convert this calculation to SIMD", or "when the content type is application/zlib, decode the body"[^aiworkexamples]. I have set up some custom instructions to only give me the code that has changed, and give me instructions for adding it. This way, I am still the one making the changes in the codebase. Just approving a Git diff is not enough, I want to manually add the code myself, only then do I feel confident to sign off on it and take responsibility for it.
[^aiworkexamples]: The specific contents here don't matter that much, they are just examples of what I use AI for
Another great use case for AI is learning. I often have questions that are quite uncommon, as I have a few very niche interests. Turns out, adding netcode to a custom game engine using ECS doesn't have a lot of learning resources. What has worked for me, is asking AI to explain pieces of code, like "explain this assembly code", "explain what this shader does", "which books go in-depth about resolving client/server desyncs in game engines". The AI seems to struggle with these sometimes, I'm getting mixed results, but the results are still much better than search engines. I will even use it for this article, though not for writing content, but for checking[^checkprompt].
[^checkprompt]: The prompt I've used for this article: "I want you to proofread an article I have written. I want you to give me feedback on incorrect grammar or broken sentences, using UK grammar. Do not comment on sentences that should be broken up or things that could be improved just slightly, only real errors. Do not return modified sentences, but point out where the issue is, under which paragraph, in which sentence and what the mistake is. I will make the required changes myself". It came back with a few typos, like "form" that should be "from", "eb" that should be "be".
Another benefit of using AI this way is the cost. No unnecessary API calls, manually managed contexts and more control over the LLM settings. I use a desktop application with a bunch of different LLMs hooked up to it. I have used it daily for the last 3 months or so, and in total, I have consumed around $4 in credits.
I do want to add that with some things I am more strict. On my personal website, I don't want any AI-generated content, whether that's text or images. I don't like AI generated images or 'art' personally for various reasons and I think AI-generated text lacks character, it feels very flat and boring. When something is created by humans, it to me has more value than when it's created by AI.
## Doing what you love
It is also worth noting that there are more things to think about than efficiency and productivity. It's also about doing what you love. If you love coding, keep doing it yourself, even if a computer might be better at it.
In 1997, Deep Blue won the chess match against the then world chess champion Garry Kasparov[^deepblue], yet people still play chess. When it comes to programming, I'd say that I program for the same reason that people still play chess[^chessclip]. Though chess and software development are very different, with chess being much more limited in scope, I think it is good to keep in mind that sometimes, we can do things just to enjoy them.
[^deepblue]: Source: [IBM History](https://www.ibm.com/history/deep-blue)
[^chessclip]: Source: [Tsoding on YouTube](https://youtu.be/-eS5-kaTSD0?si=Mf3ySN8QbpWhgLgK&t=328)
## My advice to new programmers
Don't become a forever junior who lets AI do all their work. If you want to become a programmer, learn to program yourself. Be curious, put in the time and effort to learn how things really work, and how things work in the layer below that. It really pays off. Learning how everything works under the hood and using that is amazing, just keep learning, don't be a prompt engineer (if you can even call that engineering). Believe me, it's more fun to be competent[^funcompetent].
[^funcompetent]: Source: [DHH in an interview on YouTube](https://youtu.be/mTa2d3OLXhg?si=vfjLD1DeoPZMERxA&t=1126)
Even though AI might be smarter than you, never blindly trust the AI output. Don't build your whole workflow around it. Sometimes try to work without it for a few days. The better at programming you are, the more AI will get in your way for the more complex work.
If you learn to code now, keep building your skills instead of letting AI do all the heavy lifting, you'll be capable of fixing the messes that vibe coding is now creating. I don't want to sound elitist, but if you don't want to learn to go beyond vibe coding, maybe coding isn't for you. Because positions where all work can be done by vibe coding are the ones that will be eliminated first when AI becomes more powerful.
And remember: if you cannot code without AI, you cannot code.
## Conclusion
When you are using AI, you are sacrificing knowledge for speed. Sometimes it's worth making this trade-off. Though it is important to remember that even the best athletes in the world are still doing their basic drills for a reason. The same applies to software development: you need to practice the basics, to be able to do the advanced work. You need to keep your axe sharp.
We are still a long way out from AI taking over our jobs. A lot of companies are creating FOMO[^fomo] as a sales tactic to get more customers, to show traction to their investors, to get another round of funding, to generate the next model that will definitely revolutionize everything.
[^fomo]: Fear of missing out
AI is a tool, it is not good or bad in itself, it's what you do with it. I do think it can be a great tool, as long as you are not reliant on it for your workflow. Make sure you can still work effectively without it, make sure you don't push code to production that you don't fully understand and don't think of AI as a replacement for your own thinking. Stay curious, keep learning.
@@ -0,0 +1,625 @@
+++
date = "2025-04-16"
title = "Creating and validating JWTs with public/private keys (JSON Web Key Set, JWKS)"
slug = "creating-jwt-with-public-private-keys-jwk"
tags = ["JWT", "Security", "Encryption", "Golang", "Typescript"]
categories = ["Backend"]
[params]
metadescription = "How to securely sign and validate JWTs using public/private key pairs and leverage JSON Web Key Set (JWKS)"
metakeywords = 'jwt, jwk, jwks, public private key, authentication, jose, token validation'
+++
_This article discusses an implementation of JWT generation in Go, and validation in Go and Typescript. The library used for this, jose, has implementations for many other programming languages, so the principles can be applied in nearly all commonly used programming languages._
JSON Web Token[^jwtrfc] (JWT for short) is a very commonly used way to create and validate authentication tokens on the web. This article is not a tutorial of JWT basics, there are many resources available for that online already.
Most often, when generating JWTs, a secret value is used to create the third part of the JWT, the signature. This approach is great when there is a single service that generates and validates tokens. In some cases though, you want to sign a token on one service, and have it validated by another service. This could be achieved by sharing the signing secret, but this is not an optimal solution in terms of security, you would rather sign a JWT on one service and then validate it on another service, in a way that doesn't require you to have access to the secret value.
This can be achieved by using private/public key encryption. You can sign a JWT with a private key, and in the header include the link to where the public key can be found to validate the signature. This will allow other services to validate a JWT, without requiring access to the actual secret value to sign a key. To make this more concrete: this system is used by for example Auth0[^authzero] to sign keys to then allow you to validate these tokens without needing access to the signing secret. This system is also used by AWS Cognito[^awscognito].
I recently integrated this into [Capsa.gg](https://capsa.gg). The web panel has middleware to validate JWTs, which will redirect a user to log in if the key is invalid. When I started implementing this, I was not able to find a lot of resources on this unfortunately and it took quite some trial-and-error to complete. Yet in the end, implementing JWTs with private key signing and public key validation was quite straight-forward. Hence why I'm sharing my findings here, in the hope it will be useful to others.
This article will implement JWT generation with a private key and gives an example of validating with the private key. Some code is omitted, a working example project can be found on Github: [lucianonooijen/jwt-public-private-key-demo](https://github.com/lucianonooijen/jwt-public-private-key-demo). This also contains some unit tests for validation, which are not included in this article.
> [!CAUTION]+ Security disclaimer
> Please don't follow this guide as if it's gospel, make sure you know what you are doing. This article and the example project are meant as starting points, to be adopted according to specific needs. Never blindly trust code on the internet, especially when it comes to security. The footnotes of this article contain trusted sources that can be used for further reading.
[^jwtrfc]: [RFC7519](https://datatracker.ietf.org/doc/html/rfc7519)
[^authzero]: [Auth0 JSON Web Key Sets](https://auth0.com/docs/secure/tokens/json-web-tokens/json-web-key-sets)
[^awscognito]: [AWS Cognito Token Validation](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html#amazon-cognito-user-pools-using-tokens-aws-jwt-verify)
## JSON Web Key
When generating a JWT, the first part of the token, the header, contains information about how the key is signed. The most important field for our use-case, is the algorithm field `alg`.
For example, when signing a token with just a simple secret, the header contents are
```json
{
"alg": "HS256",
"typ": "JWT"
}
```
indicating that the algorithm used is "HMAC using SHA-256"[^hs256]. This is the easiest way to sign and verify JWTs, used in many projects. You need a secret to sign the token, and use the same token to validate it.
Now let's look at the header of a JWT that has been signed with the public/private key setup that this article discusses:
```json
{
"alg": "RS256",
"jku": "http://localhost:4000/.well-known/jwks.json",
"typ": "JWT"
}
```
The algorithm field here now has a different value, `RS256`, indicating the digital signature algorithm used is "RSASSA-PKCS1-v1_5 using SHA-256"[^rs256].
This looks like a cat walked across the keyboard, but we can break this down. `RSA` indicates that we are using RSA, so a public/private key setup, `SSA` indicates "With Appendix", meaning the signature is separate from the message, which makes sense with how JWTs work. `PKCS1` refers to the "Public-Key Cryptography Standards #1"[^pkcsone], which defines how RSA should be used. `v1_5` indicates the padding scheme, which is older, but used and supported in JWTs.
A new field in the header, is the `jku` field. This is the JWK Set URL[^jku], often being `<service_url>/.well-known/jwks.json`[^jwksuri]. The URL here should resolve to the public key data when making a GET request to this URL over TLS[^jku-optional]. The value of this field should not be blindly trusted at runtime unless it matches an expected value, or there is the risk of a key injection attack. When fetching this URL, there is a specific format how the data should be encoded[^jwkformat], which is called the JSON Web Key Set, or JWKS. "Set" here meaning a collection of multiple JSON Web Keys (singular: JWK). We will discuss this a bit more later. The important takeaway here is: this URL contains the public key data of all signing keys that should be considered valid.
A small note to add: for the example project, we are not fully following the RFC, as we fetch the JWKS without TLS. When implementing a JWKS endpoint, it should always be to over TLS to follow the RFC[^jku].
To summarize: when implementing public/private key signing for JWTs, the JWT should contain the correct algorithm, as well as a URL where valid keys can be retrieved for validating the tokens. There is a specific format on how the public keys must be made available.
<!--
mmdc -i mermaid.mmd -o static/img/0011-1.png -b transparent
```mermaid
sequenceDiagram
participant A as Auth
participant S as Service
participant U as User
par Authentication
U->>A: Request JWT via login
A->>U: Sends JWT, signed with private key
end
par AuthenticatedRequest
U->>S: Sends request with JWT
opt Fetch JWKS
S->>A: Request JWKS
A->>S: Send JWKS
end
S- ->S: Validates JWT validity using JWKS || TODO: REMOVE SPACE
S->>U: Sends response based on JWT validity
end
```
-->
![jwt with jwks sequence diagram](/img/0011-1.png)
[^pkcsone]: [RFC8017](https://datatracker.ietf.org/doc/html/rfc8017)
[^jwkformat]: [RFC7517: JSON Web Key (JWK) Format](https://datatracker.ietf.org/doc/html/rfc7517#section-4)
[^hs256]: [RFC7518: HMAC with SHA-2 Functions](https://datatracker.ietf.org/doc/html/rfc7518#section-3.2)
[^rs256]: [RFC7519: Digital Signature with RSASSA-PKCS1-v1_5](https://datatracker.ietf.org/doc/html/rfc7518#section-3.1)
[^jku]: [RFC7515: "jku" (JWK Set URL) Header Parameter](https://datatracker.ietf.org/doc/html/rfc7515#section-4.1.2)
[^jwksuri]: This is not a strict requirement for our use-case here, but comes from the conventions for OAuth2.0 Authorization Server Metadata. This is also the endpoint used by Auth0 and AWS Cognito. See [RFC8414: Authorization Server Metadata](https://datatracker.ietf.org/doc/html/rfc8414#section-2) for more info.
[^jku-optional]: The RFC marks this field as optional but for our use-case we want to include this field.
## Public/private key generation
So, let's start implementing JWT signing with public/private keys. The first thing we need, is to actually generate the keys. We need to generate keys with at least 2048 bits[^keysize].
We don't want to generate a key, keep it in memory and use that right away. The keys should be reused if the application starts up. You can generate the key in any way you like, this is an example of how to do this in Go. Let's assume we want to create a private key, then extract the public key from that, encode that as strings that we will return to the calling function, which will write the keys to disk.
```go
package token
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
)
// RsaKeySet is a set of generated public and private keys.
type RsaKeySet struct {
PrivateKey []byte
PublicKey []byte
}
// GenerateRsaKeySet generates a private/public key set for signing JWKs.
func GenerateRsaKeySet() (*RsaKeySet, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return nil, fmt.Errorf("error generating private key: %w", err)
}
return EncodePrivateKeyToBytes(privateKey)
}
// EncodePrivateKeyToBytes takes in a *rsa.PrivateKey and encodes this to the private and public PEM key bytes.
func EncodePrivateKeyToBytes(privateKey *rsa.PrivateKey) (*RsaKeySet, error) {
privateKeyBytes := encodePrivateKeyToPEM(privateKey)
publicKeyBytes, err := encodePublicKeyToPem(privateKey)
if err != nil {
return nil, fmt.Errorf("error generating public key: %w", err)
}
keyData := RsaKeySet{
PrivateKey: privateKeyBytes,
PublicKey: publicKeyBytes,
}
return &keyData, nil
}
func encodePrivateKeyToPEM(privateKey *rsa.PrivateKey) []byte {
// Get PKCS #1, ASN.1 DER format
privateKeyContents := x509.MarshalPKCS1PrivateKey(privateKey)
// pem.Block
privateBlock := pem.Block{
Type: "RSA PRIVATE KEY",
Headers: nil,
Bytes: privateKeyContents,
}
// Private key in PEM format
privatePEM := pem.EncodeToMemory(&privateBlock)
return privatePEM
}
func encodePublicKeyToPem(privateKey *rsa.PrivateKey) ([]byte, error) {
// Extract the public key
publicKey := &privateKey.PublicKey
// Marshal the public key to PKIX, ASN.1 DER form
publicKeyBytes, err := x509.MarshalPKIXPublicKey(publicKey)
if err != nil {
return nil, fmt.Errorf("error marshaling public key: %w", err)
}
// Create the PEM block
publicKeyPEMBlock := pem.Block{
Type: "PUBLIC KEY",
Bytes: publicKeyBytes,
}
// Encode the PEM block to []byte
publicKeyPEM := pem.EncodeToMemory(&publicKeyPEMBlock)
return publicKeyPEM, nil
}
```
_Security disclaimer: keys must be handled with care. You need to use different keys for different environments and have proper practices for handling secrets. Please make sure you handle key generation and storage with great care._
[^keysize]: [RFC7518: Digital Signature with RSASSA-PKCS1-v1_5](https://datatracker.ietf.org/doc/html/rfc7518#section-3.3)
## Accessing the public and private key
So we have generated a private and public key and persisted the contents to disk. Let's now work on the actual JWT logic that uses these keys. In practice, we don't need to load the public key from disk, only the private key, which we will use to get the public key values.
So let's write two helper functions: one that will load the private key from disk, and another that will decode a base64 string that is passed into it, for example if the private key value is set as an environment variable, base64 encoded.
```go
package token
import (
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"os"
)
const blockTypeRsaPrivateKey = "RSA PRIVATE KEY"
var (
// ErrorPemPrivateKeyDecoding indicates that the decoding of the private-key containing PEM block failed.
ErrorPemPrivateKeyDecoding = errors.New("failed to decode PEM block containing private key")
)
// LoadPrivateKeyFromPath reads the private key from a file and returns a rsa.PrivateKey struct.
func LoadPrivateKeyFromPath(path string) (*rsa.PrivateKey, error) {
// Read the file
keyBytes, err := os.ReadFile(path) //nolint:gosec // "G304: Potential file inclusion via variable", this path is set in the config.yml and should never contain user input
if err != nil {
return nil, fmt.Errorf("failed to read private key file: %w", err)
}
// Decode the PEM block
block, _ := pem.Decode(keyBytes)
if block == nil || block.Type != blockTypeRsaPrivateKey {
return nil, ErrorPemPrivateKeyDecoding
}
// Parse the private key
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse private key: %w", err)
}
return privateKey, nil
}
// LoadPrivateKeyFromBase64String reads the private key from a base64 string and returns a rsa.PrivateKey struct.
func LoadPrivateKeyFromBase64String(b64 string) (*rsa.PrivateKey, error) {
keyBytes, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return nil, fmt.Errorf("failed to decode private key base64 string: %w", err)
}
// Decode the PEM block
block, _ := pem.Decode(keyBytes)
if block == nil || block.Type != blockTypeRsaPrivateKey {
return nil, ErrorPemPrivateKeyDecoding
}
// Parse the private key
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse private key: %w", err)
}
return privateKey, nil
}
```
We now have our `*rsa.PrivateKey` instance again that we can use to sign JWTs with.
## Signing JWTs
To sign the JWTs, we will be using go-jose[^gojose]. Let's create a struct called `Token`, which holds the data we need, and exposes methods to sign and validate tokens:
```go
import (
"crypto/rsa"
"errors"
"fmt"
"github.com/go-jose/go-jose/v4"
)
type Token struct {
privateKey *rsa.PrivateKey
jwk *jose.JSONWebKey
signer *jose.Signer
}
```
With our `Token` struct defined, let's create a function called `New` in our package, that will accept a private key and returns an initialized `Token` instance:
```go
const (
keyID = "jwt-demo-server-jwk"
algorithm = jose.RS256
)
// New returns a Token instance after validating the *rsa.PrivateKey.
func New(pk *rsa.PrivateKey) (*Token, error) {
if pk == nil {
return nil, errors.New("private key argument is required")
}
if err := pk.Validate(); err != nil {
return nil, fmt.Errorf("error validating private key: %w", err)
}
jwk := jose.JSONWebKey{
Key: pk,
Use: "sig",
Algorithm: string(algorithm),
KeyID: keyID,
}
signerOptions := (&jose.SignerOptions{}).
WithHeader("alg", algorithm).
WithHeader("typ", "JWT").
WithHeader("jku", "http://localhost:4000/.well-known/jwks.json")
jwtSigner, err := jose.NewSigner(jose.SigningKey{
Algorithm: algorithm,
Key: pk,
}, signerOptions)
if err != nil {
return nil, fmt.Errorf("error creating jwt signer: %w", err)
}
jwkInstance := Token{
privateKey: pk,
jwk: &jwk,
signer: &jwtSigner,
}
return &jwkInstance, nil
}
```
A few important notes here: the `keyID` is an identifier for which type of key we are dealing with. If you have two token types: a user and an admin, you want to use different key IDs, which other services should validate if it's the right `keyID`. The `jku` field is now hardcoded in this example, you will most likely need to add a method that uses the application config to generate the correct URL, which is using `https`, not `http` like in this example. In the `jose.JSONWebKey`, we set `Use: "sig"`, because we use this key for signing, not for encrypting the JWT[^useparam], that is beyond the scope of this article.
With our new `Token` instance, let's add a few methods that allows us to actually generate and validate JWTs. For our example application, we will only have a single token type. When dealing with multiple token types, you will most likely have to generate multiple functions for generation and validation, for each token type.
Let's generate a JWT:
```go
const (
Audience = "example"
Issuer = "jwt-demo-server"
)
func (t *Token) GenerateJwt(subject, jwtId, name, role string) (string, error) {
now := time.Now()
expiryHours := time.Duration(48) * time.Hour
claims := JwtClaims{
Issuer: Issuer,
Subject: subject,
Audience: Audience,
Expiry: now.Add(expiryHours).Unix(),
NotBefore: now.Unix(),
IssuedAt: now.Unix(),
JwtID: jwtId,
Name: name,
Role: role,
}
tok, err := t.generateTokenForClaims(claims)
if err != nil {
return "", fmt.Errorf("error signing token: %w", err)
}
log.Printf("generated client jwt with subject: %s\n", subject)
return tok, nil
}
// generateTokenForClaims is a function that will sign the JwtClaims passed in.
// WARNING: This method should only be called in public wrapper functions and not exposed directly.
// For generating tokens in production code, always use the audience-specific methods.
func (t *Token) generateTokenForClaims(claims JwtClaims) (string, error) { //nolint:gocritic // Jose needs val, not ref
return jwt.Signed(*t.signer).Claims(claims).Serialize()
}
```
The scary looking second function performs the logic to actually generate the JWT string with the claims passed in. Discussing the JWT claims is beyond the scope of this article, these are explained in the RFC[^jwtrfc]. An example can be found [in the example project](https://github.com/lucianonooijen/jwt-public-private-key-demo/blob/main/server/internal/token/claims.go). The note "For generating tokens in production code, always use the audience-specific methods." refers to using wrapper functions to generate keys with the correct data.
[^gojose]: [go-jose/go-jose](https://github.com/go-jose/go-jose/)
[^useparam]: [RFC7517: "use" (Public Key Use) Parameter](https://datatracker.ietf.org/doc/html/rfc7517#section-4.2)
## Validating JWTs using the private key
Of course we also want to be able to validate the JWT on the service that generated the key. Let's add that before we implement the validation on an external service:
```go
var (
// ErrorJwtParsing indicates that the signature of the JWT is not valid.
ErrorJwtParsing = errors.New("parsing JWT failed")
// ErrorJwtValidation indicates that the token claims could not be validated.
ErrorJwtValidation = errors.New("claim validation for JWT failed")
// ErrorJwtConversion indicates that the token claims to struct conversion failed.
ErrorJwtConversion = errors.New("claim conversion for JWT failed")
)
func (t *Token) ValidateJwt(token string) (*JwtClaims, error) {
parsedToken, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{algorithm})
if err != nil {
log.Printf("error parsing signed token: %s\n", err)
return nil, ErrorJwtParsing
}
claims := Claims{}
// Note: dereference here is very important!
// The jose code checks for *rsa.PublicKey specifically, and does not accept rsa.PublicKey
err = parsedToken.Claims(&t.privateKey.PublicKey, &claims)
if err != nil {
log.Printf("error validating token claims: %s\n", err)
return nil, ErrorJwtValidation
}
c, err := claims.jwtClaims()
if err != nil {
log.Printf("error converting token claims: %s\n", err)
return nil, ErrorJwtConversion
}
err = validateJwtClaims(c)
if err != nil {
log.Printf("error validating token claims: %s\n", err)
return nil, err
}
return c, nil
}
```
This code assumes a `type Claims map[string]any` definition. On this type, we add a method `jwtClaims` that converts this map to our own custom claims type, which was mentioned before. For inspiration, you can check [this example](https://github.com/lucianonooijen/jwt-public-private-key-demo/blob/main/server/internal/token/claims.go). We also call our own `validateJwtClaims` method, which is beyond the scope of this article, but it validates a bunch of fields, you can draw inspiration from [this example](https://github.com/lucianonooijen/jwt-public-private-key-demo/blob/main/server/internal/token/jwt_validate_claims.go). The validation required depends on your application. You should also validate that the algorithm and key id match the expected values, or reject the token if they don't.
## Sharing the public key
Remember that when signing tokens with RS256 (public/private key setup), that we should include a URL where the JWKS can be retrieved? Let's add that now.
In our `Token` instance, we want to add a method that allows us to retrieve the JSON representation of the public key. Getting the JSON representation of the public key is very straight-forward:
```go
// GetPublicKey takes the public part of the private key and marshals this to JSON for the .well-known/jwks.json endpoint.
func (t *Token) GetPublicKey() ([]byte, error) {
// SECURITY NOTE: NEVER REMOVE `.Public()` FROM THIS CODE
// Otherwise, the private key will be shared and the whole token system will be compromised!
return t.jwk.Public().MarshalJSON()
}
```
**Make sure you use `jwk.Public()`!** If you do not include the `Public()` part in here, you will expose the full _private_ key, meaning the whole token system will be compromised!
We can add a simple handler to return this key like this, assuming we have our handlers in a `handlers` struct which has an instance of `Token` in the `token` struct field:
```go
func (h *handlers) Jwk(c *gin.Context) {
pubKey, err := h.token.GetPublicKey()
if err != nil {
// handle error
return
}
c.Header("content-type", "application/json")
// Manually writing JSON for compatibility with the []byte from pubKey
c.Writer.WriteString(`{"keys":[`) //nolint:errcheck,gosec // This is fine
c.Writer.Write(pubKey) //nolint:errcheck,gosec // This is fine
c.Writer.WriteString(`]}`) //nolint:errcheck,gosec // This is fine
c.Status(http.StatusOK)
}
```
The proper JSON marshalling is left as an exercise for the reader.
## Inspecting the JWT
Let's add a super simple API endpoint `GET /jwt` that generates a JWT with some hardcoded values. This of course is just for demo purposes and not secure to be used in production. In production, add authentication checks for users to log in and use the proper JWT claim values.
Example handler for testing:
```go
func (h *handlers) GetJwt(c *gin.Context) {
// Never do this in production, just for demo!
jwt, err := h.token.GenerateJwt("42", "1337", "John Doe", "Example")
if err != nil {
// Handle error
return
}
// Validate the token passes our own validation, plus get the claims so we can return the expiry time
claims, err := h.token.ValidateJwt(jwt)
if err != nil {
// Handle error
return
}
c.JSON(http.StatusOK, struct {
Token string `json:"token"`
Expiry int64 `json:"expiry"`
}{Token: jwt, Expiry: claims.Expiry}) // You should do this properly
}
```
So to test, let's run the server and run `curl http://localhost:4000/jwt | jq '.token'`[^jq] to generate a token, and parse the JWT string from the response. Let's copy the value into [jwt.io](https://jwt.io). We can see that the header values and claims are set correctly.
[^jq]: jq is a very helpful tool for working with JSON data on the command line, but how to use it are beyond the scope of this article.
## Validating the JWT in NextJS
So now we get to the exciting part, validating the JWT in a separate service. Let's initialize an empty NextJS project and add some JWT validation middleware:
middleware.ts:
```ts
import {
deleteJwtCookie,
getJwtCookieFromRequest,
} from "@/data/jwt/cookiesServer";
import JwtValidator from "@/server/jwt";
import { type NextRequest, NextResponse } from "next/server";
// This middleware checks if users are logged in.
// This is not implemented for security reasons, as that is done on the server.
// It simply serves as a way to make sure users don't get 4xx errors.
export async function middleware(req: NextRequest) {
const { pathname, search, origin, basePath } = req.nextUrl;
const path = `${basePath}${pathname === "/" ? "" : pathname}${search}`;
const logBase = `[middleware][${req.method} ${path}]:`;
const token = await getJwtCookieFromRequest(req);
const isAuthenticated = token ? await JwtValidator.ValidateJwt(token) : false;
const isAuthRoute = req.nextUrl.pathname.startsWith("/auth");
console.log(
logBase,
"received request, isAuthenticated",
isAuthenticated,
"| isAuthRoute",
isAuthRoute,
);
// Logged-in users accessing login routes should be redirected to the homepage
if (isAuthenticated && isAuthRoute) {
console.log(
logBase,
"redirecting logged in user from auth route to",
req.url,
);
return NextResponse.redirect(new URL("/", req.url));
}
// Send users who are not logged in and request non-auth pages to the login page
if (!isAuthenticated && !isAuthRoute) {
await deleteJwtCookie(req);
const signInUrl = new URL(`${basePath}/auth/login`, origin);
if (path !== "") {
signInUrl.searchParams.set("redirect", path);
}
console.log(
logBase,
"redirecting non logged in user to",
signInUrl.toString(),
);
return NextResponse.redirect(signInUrl);
}
console.log(logBase, "continue executing request");
return NextResponse.next();
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico, sitemap.xml, robots.txt (metadata files)
*/
"/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
],
};
```
The actual `JwtValidator` implementation will be specific for each application and should take care to properly validate tokens. The implementation for the demo project can be found [here](https://github.com/lucianonooijen/jwt-public-private-key-demo/blob/main/client/server/jwt.ts). For validation, you should think of checking the algorithm, expected audience, issuer and timestamps.
It is good practice to set the JWKS endpoint in your application config, so that you only fetch keys from that location and reject keys that have a different `jku` field in the header. Don't trust the `jku` field from the header and start fetching the key from there, in case a malicious actor set up their own service to sign keys. Security here is very important, so take good care to minimize the possible attack vectors.
## Seeing this in action
The example project for this article can be found [here](https://github.com/lucianonooijen/jwt-public-private-key-demo) on GitHub.
For a more complete example with different types of keys, you can take a look at [Capsa's token package](https://github.com/capsa-gg/capsa/tree/main/server/internal/infrastructure/token) and at [Capsa's NextJS middleware](https://github.com/capsa-gg/capsa/blob/main/web/middleware.ts).
> [!CAUTION]+ Disclaimer
> Never blindly trust code on the internet, especially when it comes to security. Use this article as a starting point but please do your own research and familiarize yourself with the relevant RFCs when implementing this for a production application.
<br />
+16
View File
@@ -0,0 +1,16 @@
+++
title = "Contact"
slug = "contact"
+++
I used to share my email here in plain text, but I started receiving a lot of spam from people scraping this website.
You can email me at _iamnotabot{at}lucianonooijen.com_, or drop me a message on LinkedIn.
Want to connect on LinkedIn? [Feel free to add me](https://linkedin.com/in/lucianonooijen).
I can also be found on [GitHub](https://github.com/lucianonooijen/) and on [YouTube](https://www.youtube.com/@LucianoNooijen)
## Schedule a call appointment
To prevent phone spam, I don't publically share my number, but feel free to reach out via email or LinkedIn to schedule a call.
+43
View File
@@ -0,0 +1,43 @@
+++
title = "Development environment"
slug = "environment"
+++
_Note: this page reflects my personal configuration, as my work requires me to work on Windows._
#### Development environment
I often get questions about what my local development environment looks like and how everything is configured. For this reason, I have summarised everything on this page.
###### Operating system
For the majority of the time I use [Arch Linux](https://www.archlinux.org/), or Mac OS. I would prefer to do everything within Arch Linux, but unfortunately this is not feasible because I regularly have to compile iOS apps, which require Mac OS.
Recently, I started experimenting with using FreeBSD instead of Linux-based distro's. I don't have a lot of time to explore here, but I am planning to port my dotfiles to BSD in the near future.
##### Arch config
Within Arch Linux I use [dwm](https://dwm.suckless.org/) as window manager, [Ranger](https://github.com/ranger/ranger) as file manager and
[ZSH with Oh My ZSH](https://ohmyz.sh/) within [ST (Simple Terminal)](https://st.suckless.org/) as terminal.
##### Code Editors
I use two different code editors: [NeoVim](https://github.com/neovim/neovim) (a better and backwards-compatible fork from [Vim](https://www.vim.org/)) and [Visual Studio Code](https://github.com/Microsoft/vscode) (abbreviated: VS Code).
I prefer to work with NeoVim, but for many projects I still use VS Code because it offers more [IDE functionalities](https://en.wikipedia.org/wiki/Integrated_development_environment), which allows me to be more productive when working in mid-size codebases.
In addition, I regularly use [IntelliJ IDEA](https://www.jetbrains.com/idea/), because for large-scale projects code editors like (Neo)Vim and VS Code unfortunately can't provide enough code intelligence support to work effectively.
Within both my code editors and my terminal I use the Monokai colors, because I experience them as very clear and pleasant.
##### Dot files (config files)
My "[dotfiles](https://www.quora.com/What-are-dotfiles)" can be found in [my dotfiles repository](https://github.com/lucianonooijen/dotfiles) on GitHub.
Because setting up Arch Linux can be a fairly labor-intensive job, and I like to automate things, I automated this process using [Luke Smith](https://lukesmith.xyz)'s scripts. These "bootstrapping scripts" can be found in [the LARBS repository](https://github.com/lucianonooijen/LARBS) on my GitHub profile.
Feel free to use and modify my dot files for your own use!
##### Any questions or need help?
I'm very passionate about how I've configured everything, and I'm happy to help others. If you have any questions, [email me](mailto:luciano@lucianonooijen.com)!
+14
View File
@@ -0,0 +1,14 @@
+++
title = "Professional work experience"
slug = "experience"
+++
## Game titles
{{< mdfile file="assets/blocks/gametitles.md" >}}
## Positions
<div class="block-experience">
{{< mdfile file="assets/blocks/experience.md" >}}
</div>
+22
View File
@@ -0,0 +1,22 @@
+++
title = "Skills and Expertise"
slug = "expertise"
+++
With more than a decade under my belt in software development, I have dealt with a tremendous amount of programming languages and frameworks, while specializing in a handful. Although I have my preferences, I will always pick the technology that makes the most sense for a project, both short- and long-term.
I have experience in hands-on software development positions, as well as leadership positions, with responsibilities that extend further than lines of code (determining roadmaps, mentoring developers, business decisions).
{{< mdfile file="assets/blocks/technologies.md" >}}
---
{{< mdfile file="assets/blocks/workmethod.md" >}}
---
## Learning
Currently, I spend around 20 hours per week on self-education. Some of it focused on computer science, most of it on other subjects (like philosophy, history and linguistics). You can read more on my [learning page](/learning).
<!--The old learning page is archived [here](/archive/learning).-->
+67
View File
@@ -0,0 +1,67 @@
+++
title = "Freelance beschikbaarheid (Nederlands)"
slug = "freelance-nl"
aliases = ["/freelance-nl"]
+++
[Click here to read in English](/freelance)
#### Introductie
Omdat mijn beschikbaarheid en wensen voor projecten regelmatig veranderen, heb ik deze pagina op mijn website toegevoegd, speciaal voor recruiters, waarop ik antwoord kan geven op de meeste vragen.
Om te lezen over mij als persoon en mijn achtergrond, ga naar de [about](/about)-pagina.
Om mijn CV te bekijken, zie mijn [resume](/resume) pagina.
#### Beschikbaarheid en reistijd
**Momenteel ben ik _wel_/~~niet~~ beschikbaar voor freelance/consultancy/ad-hoc projecten**
Mijn beschikbaarheid per week is:
- In totaal maximaal 8-16 uur per week
- Vrijheid mbt. werktijden
- Volledig remote
Momenteel hanteer ik een uurtarief van €80-90 afhankelijk van de details van het project. Dit uurtarief is all-in, maar exclusief omzetbelasting.
_Ik ben niet beschikbaar voor full-time posities of projecten waarbij werkzaamheden structureel on-site verricht moeten worden._
<!--_Mijn voorkeur gaat uit naar een project met een initiële looptijd van maximaal 3 maanden, met verlenging in overleg_.-->
#### Technologie
Naast consultancywerk voor software start-ups ben ik momenteel beschikbaar voor hands-on werk bij een project met een of meerdere van de volgende technologieën:
- Unreal Engine 5 of custom C/C++ voor gameontwikkeling
- Golang voor back-end
- ReactJS (/Next) voor front-end
- React Native (/Expo) voor mobile
- DevOps/Cloud-automatisering met Linux-omgevingen
- Site Reliability Engineering
Om meer te lezen over mijn skillset, ga naar de [expertise-pagina](/expertise). Hier wordt ook mijn werkwijze verder toegelicht.
<!--
#### Werkzaamheden en sector
Ik hou erg van afwisseling, maar heb een lichte voorkeur om binnen de games-industrie te blijven. Echter sta ik ook open voor posities in andere sectoren indien de positie binnen mijn interesses valt.
Zo liggen mijn interesses vooral bij:
- _Gameontwikkeling_ van online games, met een focus op online/multiplayer
- _Game engine programming_ voor het bouwen of uitbreiden van game engines, met een focus op networking
- De _ontwikkelaarervaring_ verbeteren met _CI/CD_ om development en operations te versnellen
- Het _uitdenken_ en _ontwikkelen_ van interne tools om (development)-processen te verbeteren
- _Software architectuur_ en het bepalen van de richting op technisch gebied binnen bedrijven of teams
- _IT-automatisering_ binnen een bedrijf en _systems integration_ (koppelingen maken tussen verschillende applicaties)
- Bijdragen aan _open-source/vrije software-projecten_ van bedrijven
- _Start-ups_ waarbij software de focus heeft
-->
_Ik sta open voor zowel greenfield als brownfield projecten, zonder sterke voorkeur_
#### Vragen of contact
Voor vragen, of voor een kennismaking ben ik te per email te bereiken op [freelance@lucianonooijen.com](mailto:freelance@lucianonooijen.com), of via [LinkedIn](https://www.linkedin.com/in/lucianonooijen/).
+63
View File
@@ -0,0 +1,63 @@
+++
title = "Freelance availability"
slug = "freelance"
aliases = ["/freelance"]
+++
[Klik hier voor de Nederlandse versie](/freelance-nl)
#### Introduction
This page shows the up-to-date information about my availablity for freelance projects. To read about me as a person and my background, go to the [about](/about)-page. For my resume, see the [resume](/resume) page.
### Availability and travel time
**Currently I _am_/~~am not~~ open to new freelance/contract/ad-hoc work as a software engineer/consultant.**
My availability per week is:
- A total of 8-16 hours a week
- Freedom of working hours
- All remote working
My hourly rate is €80-90 depending on the project details. This hourly rate is all-in, excl. VAT.
_I am not available for full-time positions or projects that require me to work on-site._
<!--_My preference is for a project with an initial duration of at most 3 months, with extension in consultation_.-->
#### Technologies
Apart from consultancy work for software-startups, I am available for hands-on work for projects with one or more of the following technologies:
- Unreal Engine 5 or custom C/C++ for game development
- Golang for back-end development
- ReactJS (/NextJS) for front-end development
- React Native (/Expo) for mobile development
- DevOps/Cloud automation with Linux environments
- Site Reliability Engineering
To read more about my skills, go to the [expertise page](/expertise). Here, my working method is also explained in more detail.
<!--
#### Work and sector
I appreciate variety of work a lot. I have a slight preference to stay in the games industry with a preference for focus on online/multiplayer, though I am open to other positions that fit my interests too.
My interests are mainly:
- _Game developent_ of online games with a variety of tasks, with a focus on online/multiplayer functionality
- _Game engine programming_ for building or extending game engines, with a focus on networking
- Improving the _developer experience_ using _CI/CD_ to speed up development and operations
- The _inventing_ and _developing_ of internal tools to improve (development) processes
- _Software architecture_ and determining the direction in the technical field within companies or teams
- _IT automation_ within a company and _systems integration_ (creating links between different applications)
- Contributions to _open-source/free software projects_ of companies
- _Startups_ where software is the main focus
-->
_I am open to both greenfield and brownfield projects, with no preference._
### Questions or contact
For questions, or for an introduction I can be reached by email at [freelance@lucianonooijen.com](mailto:freelance@lucianonooijen.com), or via [LinkedIn](https://www.linkedin.com/in/lucianonooijen/).
+164
View File
@@ -0,0 +1,164 @@
+++
title = "Learning"
slug = "learning"
+++
Date: July 18, 2020
_Below is quite a personal story about my particular past and experiences. I have decided to share this publicly, hoping that this might help others in some way._
## How I got started
It's not a secret that I don't have formal education in Computer Science, that I don't have any formal education after high-school at all. I thought this was _the_ way to go and University was just teaching old stuff you don't need (and how wrong I was, we'll come back to that in a bit). It was at this time I was involved into some cringy programmer stuff, like passionately discussing about tabs vs spaces (and dismissing Go because it enforces tabs) or arguing Java should never be used for greenfield projects. You can say this was my '[peak of mount stupid](https://wisdomofhands.blogspot.com/2015/12/the-peak-of-mount-stupid.html)'.
Learning through freelancing and figuring things out along the way had been my way to learn until around early 2017. I realized that my 'learn as you go, when you need it'-approach had reached it's limits and there were many things I did not know that I really should know, it like learning a new (human spoken) language by just learning the new words and not the grammar. After realizing this, I started reading a lot of books on different programming languages, styles, frameworks, software architecture, site reliability engineering and soft skills. This has brought me a long way.
## Hitting a roadblock
Around the beginning of 2020, I felt like I was hitting the limit of this approach, like I knew a lot, but I could not comprehend how everything fit together. Like I've learned to play many instruments from sheet music, but never learned the underlying principles, the music theory. I could use a lot of tools, but I could not make them myself. I realized there was a lot to learn, not to learn what to do, I knew that already, but _why_ it is done this way. I started digging into topics not directly related to what I had always been focussing on (web and mobile development), I read about compiler engineering, assembly languages, virtual machines and operating systems. I learned a ton (I'd say this is the point where I turned from a software _developer_ into a true software _engineer_). But still, there were pieces missing in the puzzle. To continue with the music metaphor, I understood how symphonies were written, but I could not write them myself from start to finish.
In May of 2020, during the COVID-19 pandemic, I finally had the time for self-reflection. I came to the realization that I was utterly and completely wrong about Universities teaching you only old stuff you don't need. I realized that you need the old stuff, like calculus and algebra, to understand (and build!) the new stuff, like artificial intelligence and embedded systems systems (yes, the systems used to build embedded systems). This is something many people pointed out to me, but I was too stubborn to accept earlier. I could write a simple domain-specific language parser, but I could not prove it to be working through mathematics. I knew what my code would do with system calls, but I didn't know _how_ the CPU actually performed the task at the metal-level. I knew how to establish and use TCP connections, but I could not explain how the payloads are verified. I missed the theoretical side of computer science.
## Enrolling in formal education?
During June of 2020, I seriously considered going through a three year Bachelor's degree program, but I unfortunately could not find a University that would be a great fit, considering I still have to work full-time at Bytecode and it was already June. The Open University curriculum did not offer enough of a challenge for me and the TU Delft (less than 5 minutes from both my home and Bytecode's office) requires full-time (and I had to apply before January).
Another issue was that there would be quite a lot of overlap between the some courses' content and what I already know (especially in the first and second year), not gaining (a lot of) new knowledge while taking quite some time to complete. The thing is, my focus wasn't on getting a degree, it was purely about the knowledge gained in the process. For me, the best solution would be to follow a few Bachelor's courses to fill some knowledge gaps and then follow a Master's track (not getting a degree, just following the lectures and excercises) to gain in-depth knowledge, but this would be very unlikely to happen, as Universities don't tend to be so flexible.
## Subjects for study
Another issue I faced during this time was that in The Netherlands, there is only a very small part for study in other fields. In the US, there is a concept called a Major, in The Netherlands, the Major will be your only focus, with a 6-month period for a Minor where you can choose what to do. During my self-reflection I noticed I was laser-focussed on software engineering for many years, neglecting other parts of study that would help me develop into a better human being and in turn helping me grow in my day-job too. I wrote down what areas interest me, what I'd like to learn more about and categorized them:
- Computer science
- Fundamentals (maths and physics that allow computers to work on the metal-level, algorithms and data structures)
- Theories (theory of computation, automata, complexity theory, computability)
- Networking (how computers communicate on the lowest level, the ones and zeroes)
- Computer graphics (how it's possible to display graphics onto a screen, AR/VR)
- Compilers (the mathematical and scientific side of compilers)
- Databases (not how to use them, but how to build them, how data is stored and retrieved)
- Robotics (how embedded systems allow hardware to move, the electrical engineering behind it)
- Computational Science (computer science, applied to solve large problems)
- Cyber security fundamentals (not just how to use metasploit, but true cybersec fundamentals)
- Linguistics
- Fundamentals of linguistics
- Latin
- German
- Italian
- French
- Koine Greek
- Spanish
- Hebrew
- Arabic
- Philosophy
- Stoicism
- Epicureanism
- Platonism
- Theology
- Modern philosophy
- History
- Ancient Roman
- Ancient Greek
- Communist revolution
- American history
- Enlightenment
- Economic and political theories
- Libertarianism
- Communism/socialism
- Keynes
- Anarchism
- Music
- Music theory
- Classical music
- Jazz
- Sport
- Chess
- Golf
- Tennis
- Misc
- Rhethoric
- Speaking and presenting
- Debating and negotiation
- Meditation/mindfulness
- Storytelling
- Cooking different cuisines
- Gardening
_Note: wanting to learn about a subject is not the same as believing in it. For example, wanting to learn about socialism does not mean I'm a socialist._
As you can see, this is a pretty long list, and it does not even include the detailed subjects, like 'P vs NP' in theory of computation. But this is absolutely not meant as a 'in the coming year I will learn to'-list, far from it. It is my long-term roadmap for learning. I don't believe I can become a _true_ master at any of them, but for the subjects I can come close to mastering the subject, there can only be a select few. When I decide to learn about a topic, even when I initially finished learning about it, I will probably come back to it later, either to improve my skills even further (for example sports), or to revise my knowledge to get a quick refresher, so I can combine it with other things I've learned. When first picking up a subject, my primary goal is understanding what is needed for near-mastery of the subject. To see my own true competence and not to be over-confident (see: [Dunning-Kruger effect](https://en.wikipedia.org/wiki/Dunning%E2%80%93Kruger_effect)).
By the way, notice how there this list is pretty timeless, there is no 'Elm for front-end web development' or 'Haskell for REST API development' on the list. Most subjects would be on this list 10 years ago, and many - if not all - of them will be in 10 years. Learning Elm or Haskell are on my wishlist too, but I see that as separate from learning the fundamentals, the fundamentals I need to actually make the right choices when building with Elm or Haskell. And that's the power of University programs I was too stubborn to see, anyway, back to the topic.
## Attempting to become a self-taught computer _scientist_
As of writing this, we are halfway through July 2020, with 5 of my Bytecode team members graduating their Bachelor's degree (and 3 of them continuing to get their Master's too), just starting their summer vacation (for school, many of them spending a lot of time at Bytecode). But for me there will probably not be a lot of vacationing going on. I have decided not to enroll in any University program. I have decided to attempt to become a true computer _scientist_ on my own (well, with the help of books and the internet of course, but I mean without a University). I will try this for around one year to see if it works (and if it does, continue it afterwards). Of course I hope to succeed, but if I don't, I will probably follow some form of formal education that will allow me to fill in some gaps and then allowing me to follow a Master's program.
## How I will get started
For my learning journey, I have decided to spend around 60% (12 h/week) of my time on computer science, and 40% (8 h/wk) on a small selection of other subjects. For the computer science part of the journey, the website [Teach Yourself CS](https://teachyourselfcs.com/) has helped me a great deal for how to get started and giving me a roadmap and a list of resources to use. For the computer science part, I will start with:
- Computer science fundamentals
- Learning about how maths and physics allow computers to work the way they do, computer architecture and basic automata;
- Dipping my toes into some theories to get to know them (and go in-depth later on);
- While learning some discrete maths, physics and electrical engineering along the way
- The basics of calculus and linear algebra
- Algorithms and data structures
To do this, I will use the following (in roughly this order):
- As the resources I will use will be in English, but I only know maths in Dutch, I will use [Brilliant.org](https://brilliant.org/) and maybe some [Khan Academy](https://www.khanacademy.org/) so I can understand the English math jargon and such, this is just to understand maths in English, not to gain new knowledge
- When my math skills in English are around the same level as in Dutch, I will use the resources mentioned above to gain some additional knowledge, after that, I will continue by reading Concrete Mathematics by Donald Knuth and see if the lectures mentioned on [teachyourselfcs.com](https://teachyourselfcs.com/#math) are helpful
- For learning from the bottom (from the logic gates level) up how computers work, I will use [nand2tetris](https://www.nand2tetris.org/), although I doubt whether this will be in-depth enough.
- If I want to continue after nand2tetris, I will use the book Computer Systems: A Programmer's Perspective by Randal Bryant and David O'Hallaron
- My first goal will be to learn what's required to understand the maths in The Art of Computer Programming (TAOCP) part 1 by Donald Knuth and then work through this book cover to cover (later I will continue with part 2 to 4A). The reason I chose to go with Concrete Mathematics by Donald Knuth is because he wrote this book to understand the maths in TAOCP
- For understanding the basics of the Theory of Computation, I will use the book "Introduction to the Theory of Computation" by Michael Sipser
- For algorithms and data structures, I will follow what [teachyourselfcs.com](https://teachyourselfcs.com/#algorithms) recommends, maybe combined with reading the book Introduction to Algorithms
For the 40% left, I have decided to focus on the following subjects with the corresponding methods:
- The Latin language (using [Lingua Latina Per Se Illustrata, Pars 1](https://www.bol.com/nl/f/familia-romana/36660250/) and a [guide on how to use it](https://docs.google.com/document/d/1PB5HPz2wBDgqWXPnn5ONgZicOPv8P7LbODvkCxjpI3w/edit) I want to get the basics and then see if I want to continue with Latin or another language)
- Golf (taking lessons together with Thomas, one of my closest friends, when we have our handicaps, practice often on ourselves and then see how to progress further)
- Meditation (I've got a 1 month Headspace subscription to see whether meditation is something I want to spend time on or not)
- Chess (I will follow a short only video course on [Masterclass.com](https://masterclass.com) by Garry Kasparov to learn basic strategies, and then practice online, to get to a rating around 1000, then I'll see how to progress further)
## Closing notes
As a final note, I'd like to express that - even though I realize my changed opinion about the value of formal education - I do not regret not following the University path. I do believe it is a lot easier, to have people way with way more knowledge about certain subjects than you ever will, telling you what the best path it to gain some of this knowledge. But I also realize that this approach - whilst easier - is also very rigid, and would probably not allow for me staying at Bytecode or learning about other subjects too. Even if I'd be given the possibility to enroll at TU Delft in September, I think I'd still choose to go down the self-learning path, or at least try it out first for a year.
_I will update this page and add a log of what I have used for learning, so that others encountering the same issues as I can reference this list and take inspiration from it for their own learning path._
<br><hr><br>
## Progress and timeline
As time progresses, I will add a sort of timeline of my learnings, mostly for myself as reference later, but also so I can advice others on how to progress.
### Update February 27th, 2025
I've been getting more serious about low-level programming and game engine development. As with most things I learn, I'm starting at the basics. It's been a blast working with things like Unix sockets, SIMD operations and optimizing for cache hits, and learning more about quaternions in games, custom performant network protocols on top of UDP and how kernels handle syscalls. Learning this theory while at the same time becoming a better game engine programmer feels like a great balance between theory and practical skills.
### Update September 26th, 2024
In the last year I have shifted my learning focus on online/multiplayer game development as well as low-level programming. I have familiarized myself with Unreal Engine more, mostly in the multiplayer/online parts of the engine, as well as with custom C/C++ games. Recently, I started to pick up assembly as well as learning more about operating system and kernal programming. As part of my learning, I am catching up on some parts of mathematics as well, as this is crucial in games.
### Update September 15th, 2023
The last year and a half, I have mainly focussed on learning philosophy, history, linguistic theory and picking up Modern and Ancient Greek. Since the beginning of this year, I started working on multiplayer games, a lot of things here were new to me, to expand my knowledge I am learning more about Unreal Engine and architectural patterns in multiplayer games.
### Update January 12th, 2022
My learning is still mainly focussed on philsophy, history and linguistics. Since I have moved, I have started learning Modern Greek. I have started joining a few online reading groups about philosophy, which have greatly helped me get to know a lot of great people and great thinkers in the past.
### Update July 21st, 2021
Nearly all of my learning since the last update has been in the subjects I enjoy learning about the most, namely philosophy, history and linguistics. I have not spent much of my free time on learning about computer science. A small part of my time at Bytecode is spent on computer science learning, some of it theorethical, some of it practical. I also find playing chess and learning more about it to be very enjoyable.
### Update September 4th, 2020
My plan was to focus at least 60% on learning computer science. Well, I failed that part. It's been around 10-20%. Although I have learned quite a bit about mathematics in particular, computer science has not been my focus in the last few months.
Instead, I have spent most of my learning hours on philosophy, with some Latin during the weekends. I really, really enjoy learning about philosophy (Stoicism in particular - crazy how ancient life is so similar to the present in many ways, and how wise some guys were 2000 years ago, anyway, I digress), as it changes your modus operandi, the way you look at the world. It has been beneficial to me on multiple occasions.
Latin has been way more fun than I imagined. Using [comprehensible input](https://www.youtube.com/watch?v=fnUc_W3xE1w) with Lingua Latina has been awesome. Learning Latin this way even makes working with grammatical cases intuitive, compared to my struggle learning the German case system in high school, having to learn lists of conjugations with limited context.
I do plan to increase the computer science/mathematics part a bit - maybe to 30-40% of the total - but not to the 60% level I initially planned. One last golf-related thing: yesterday, Thomas and I got our course permission! We will practice over the winter and we are planning to get our handicap 54s in the spring of next year.
+49
View File
@@ -0,0 +1,49 @@
+++
title = "References"
slug = "references"
+++
The references were given in Dutch and translated to English. The phone numbers can be given upon request.
## Dutch
Door ir. Jan ten Kate, COO en Product Owner bij Airchip:
> Na maanden van worsteling en frustratie met ons vorige development team was Luciano Nooijen een ware verlichting. Waar vele ontwikkelaars moeite hebben om zich te verplaatsen in de opdrachtgever neemt Luciano direct verantwoordelijkheid om communicatiestromen, kostenafwegingen, ontwikkelmogelijkheden en werkstructuren helder uit te leggen en uit te voeren.
>
> Zijn technische kennis en ambitite om bij te leren staan buiten kijf maar echt uniek voor ons was de combinatie van strategisch inzicht en abstracte oplossingsgerichtheid met jeugdige leergierigheid en innovatieve kennis.
>
> Wij zochten een interim CTO voor onze start-up en Luciano heeft die rol glansrijk vervult. Hij begon met onze tech-stack uit te pluizen (Kubernetes back-end en native Android / IOS app) om vervolgens een uitgebreide plan van aanpak en risicoanalyse te maken. Samen met hem hebben we het ontwikkelteam uitgebreid en een nieuw team gestart die onder zijn leiding ons gehele product tot een hoger niveau heeft getild. Tijdens deze deze periode was de communicatie en tijd-inschatting altijd correct en werd er constant gereflecteerd of ook na zijn tijd bij ons het gedane werk goed overdraagbaar zou zijn, hetgeen zo waardevol is voor elk bedrijf wat met interim krachten werkt.
Door Pieter van der Oest, co-founder van Dearly:
> Voor de ontwikkeling van de Dearly app zijn wij een samenwerking aangegaan met Bytecode. Waar zij het technische gedeelte voor ons uit handen nemen en ondersteunen met het maken van technische maar soms ook business decisions.
>
> In deze samenwerking heeft Luciano de rol als Tech lead en developer.
>
> Luciano is een zeer gedreven, technische ontwikkelaar. In onze samenwerking zal ik hem omschrijven als iemand die de extra mile gaat en meedenkt over wat niet ‘een oplossing’ is maar wat de ‘best passende oplossing’ is.
>
> Naast dat hij een uitstekende ontwikkelaar is, is hij ook instaat om het project te overzien en kritisch vragen te stellen.
>
> Ik ervaar de samenwerking met Luciano als zeer prettig en kan hem zeker aanbevelen.
## English
By Jan ten Kate, COO and Product Owner at Airchip:
> After months of struggle and frustration with our previous development team, Luciano Nooijen was a true relief. Where many developers would struggle to put themselves in the client's shoes, Luciano immediately took responsibility to clearly explain and execute communication flows, cost considerations, development opportunities and work structures.
>
> His technical knowledge and ambition to learn are indisputable, but what was truly unique for us was the combination of strategic insight and abstract solution orientation with youthful curiosity and innovative knowledge.
>
> We were looking for an interim CTO for our start-up and Luciano filled that role with distinction. He started by figuring out our tech stack (Kubernetes back-end and native Android / IOS app) to then create a comprehensive plan of action and risk analysis. Together with him, we expanded the development team and started a new team that, under his leadership, took our entire product to the next level. During this period, the communication and time estimation was always accurate and there was a constant reflection on whether even after his time with us, the work done would be easily transferable, which is so valuable for any company working with interim forces.
By Pieter van der Oest, co-founder at Dearly:
> For the development of the Dearly app we have partnered with Bytecode. They take care of the technical part and support us in making technical and sometimes business decisions.
>
> In this partnership Luciano has the role as Tech lead and developer.
>
> Luciano is a very driven, technical developer. In our collaboration, I will describe him as someone who goes the extra mile and thinks with us about what is not 'a solution' but what is the 'best fitting solution'.
> Besides being an excellent developer, he is also able to oversee the project and ask critical questions.
>
> I experience the cooperation with Luciano as very pleasant and can certainly recommend him.
+46
View File
@@ -0,0 +1,46 @@
+++
title = "Resume Luciano Nooijen"
slug = "resume"
+++
My name is Luciano Nooijen, a software engineer with a strong foundation in computer science and a broad range of experience. My work spans from leading technical teams at FAANG-level companies to writing the first lines of code for pre-seed startups.
I specialize in multiplayer game development, focussing on engine programming and online client development. Alongside this, I am often involved in building supporting tooling, meaning I get to keep my web and backend skills sharp as well.
Beyond technical implementation, I enjoy contributing at a higher level, ensuring the right business and architectural decisions are made. As a former co-founder of Bytecode Digital Agency, I've worked with many start-ups, helping them make critical decisions and rapidly bring software to life.
To see my availability and hourly rate, please see the [freelance/recruiters](/freelance) page.
<br><hr><br>
{{< mdfile file="assets/blocks/technologies.md" >}}
<br><hr><br>
## Game titles I worked on
{{< mdfile file="assets/blocks/gametitles.md" >}}
## Professional work experience
<div class="block-experience">
{{< mdfile file="assets/blocks/experience.md" >}}
</div>
<br><hr><br>
## The human languages I speak
- **Dutch** (native)
- **English** (near-native)
- **German** (intermediate)
- **French** (beginner)
- **Modern Greek** (beginner)
- **Ancient Greek** (beginner)
- **Latin** (beginner)
<!--
<br><hr><br>
{{< mdfile file="assets/blocks/workmethod.md" >}}
-->
+22
View File
@@ -0,0 +1,22 @@
{{ define "content" }}
<style>
.error {
text-align: center;
}
.error h1 {
font-size: 2.8rem;
margin-bottom: 0.5rem;
}
.error h2 {
font-size: 2rem;
}
</style>
<section class="container centered">
<div class="error">
<h1>404</h1>
<h2>Page Not Found</h2>
<p>This page does not exist.<br /><a href="{{ .Site.BaseURL }}">Go to homepage</a></p>
</div>
</section>
{{ end }}
@@ -0,0 +1,30 @@
{{ $emojis := dict
"tip" ":bulb:"
"note" ":page_with_curl:"
"info" ":information_source:"
"warning" ":warning:"
"caution" ":exclamation:"
"danger" ":bangbang:"
}}
{{ if eq .Type "alert" }}
<blockquote class="alert alert-{{ .AlertType }}">
<div class="alert-heading">
<p class="alert-heading-emoji">
{{ transform.Emojify (index $emojis .AlertType) }}
</p>
<p class="alert-heading-text">
{{ with .AlertTitle }}
{{ . }}
{{ else }}
{{ or (i18n .AlertType) (title .AlertType) }}
{{ end }}
</p>
</div>
{{ .Text }}
</blockquote>
{{ else }}
<blockquote>
{{ .Text }}
</blockquote>
{{ end }}
@@ -0,0 +1,6 @@
<a
href="{{ .Destination }}"
{{ with .Title }}title="{{ . }}"{{ end }}
{{ if strings.HasPrefix .Destination "http" }}target="_blank" rel="noopener"{{ end }}
>{{ .Text | safeHTML }}</a
>
+47
View File
@@ -0,0 +1,47 @@
<!doctype html>
<html lang="{{ .Site.LanguageCode }}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
{{ with site.Params.author }}<meta name="author" content="{{ . }}" />{{ end }}
{{ if eq .Type "blog" }}
{{ with .Params.metadescription }}<meta name="description" content="{{ . }}" />{{ end }}
{{ with .Params.metakeywords }}<meta name="keywords" content="{{ . }}" />{{ end }}
{{ else }}
{{ with site.Params.description }}<meta name="description" content="{{ . }}" />{{ end }}
{{ with site.Params.keywords }}<meta name="keywords" content="{{ . }}" />{{ end }}
{{ end }}
<base href="{{ .Permalink }}" />
<title>{{ block "title" . }}{{ site.Title }}{{ end }}</title>
<link rel="canonical" href="{{ .Permalink }}" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Noto+Sans+Mono:400,600,700" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Anonymous+Pro:400,500,700" />
<link rel="stylesheet" href="{{ "css/styles.css" | absURL }}" />
<link rel="icon" type="image/png" href="{{ "/images/favicon.png" | absURL }}" sizes="32x32" />
{{ with .OutputFormats.Get "RSS" }}
<link href="{{ .RelPermalink }}" rel="alternate" type="application/rss+xml" title="{{ site.Title }}" />
<link href="{{ .RelPermalink }}" rel="feed" type="application/rss+xml" title="{{ site.Title }}" />
{{ end }}
</head>
<body>
<main class="wrapper">
{{ partial "header.html" . }}
<div class="content">
{{ block "content" . }}{{ end }}
</div>
{{ partial "footer.html" . }}
</main>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
{{ define "title" }}
{{ .Title }} ·
{{ .Site.Title }}
{{ end }}
{{ define "content" }}
{{ partial "list.html" . }}
{{ end }}
+13
View File
@@ -0,0 +1,13 @@
{{ define "title" }}
{{ .Title }} ·
{{ .Site.Title }}
{{ end }}
{{ define "content" }}
{{ if eq .Type "blog" }}
{{ partial "post.html" . }}
{{ else }}
{{ partial "page.html" . }}
{{ end }}
{{ end }}
+51
View File
@@ -0,0 +1,51 @@
{{ define "content" }}
<style>
.home {
text-align: center;
}
.home h1 {
margin-bottom: 1.6rem;
}
.home h2 {
font-size: 2.4rem;
margin-top: 0;
margin-bottom: 3.6rem;
}
.home ul {
list-style: none;
margin: 0;
padding: 0;
}
.home li {
display: inline-block;
position: relative;
}
.home li a {
margin: 0 0.6rem;
}
footer .ai-disclaimer {
display: none;
}
@media screen and (max-width: 1000px) {
h2 {
font-size: 1.2em !important;
}
}
</style>
<section class="container centered">
<div class="home">
<h1>{{ .Site.Params.author }}</h1>
<h2>{{ .Site.Params.info }}</h2>
{{ with .Site.Params.social }}
<ul>
{{ range sort . }}
<li><a href="{{ .url }}" target="_blank">{{ .name }}</a></li>
{{ end }}
</ul>
{{ end }}
</div>
</section>
{{ end }}
+65
View File
@@ -0,0 +1,65 @@
<style>
.footer {
line-height: 4rem;
width: 100%;
text-align: center;
line-height: 1.8em;
margin-bottom: 40px !important;
font-size: 1.4rem;
}
.ai-disclaimer {
text-align: center;
margin-bottom: 0;
}
.site-disclaimer {
font-size: 10px;
line-height: 1.4;
margin: 0 0 1rem;
}
</style>
<footer class="footer">
<section class="container">
<p class="ai-disclaimer">
<i
>All content on this website is handwritten.
<br />
This website does not contain AI-generated content.</i
>
</p>
<br style="line-height: 0;" />
&copy;
<script>
document.write(`${new Date().getFullYear()} <b>Dr. Ing. Thomas Sindt</b>`);
</script>
<br style="line-height: 8px;" />
<p class="site-disclaimer">
{{ with .Site.Params.social }}
{{ $len := sub (len .) 1 }}
{{ range $i, $e := sort . }}
<a href="{{ $e.url }}" target="_blank">{{ $e.name }}</a>{{ if lt $i $len }}&nbsp;|{{ end }}
{{ end }}
{{ end }}
</p>
<p class="site-disclaimer">
Die Website wurde mit HUGO erstellt, so wie das Internet ursprünglich gedacht war: als ein Ort, an dem Menschen ihre Gedanken und ihr Wissen frei teilen können.
<br />
Das bedeutet eine einfache, inhaltsorientierte Website, die Gedanken und Wissen kostenlos teilt.
<br />
Dies ist der Grund, warum diese Website keine Tracker, keine Werbung und keine Anforderung zur Ausführung von <span id="javascript-text">Javascript</span> hat.
</p>
<p class="site-disclaimer">Posts: CC-BY-SA | Source code: AGPL-3.0</p>
</section>
</footer>
<script>
const jsText = document.querySelector("#javascript-text")
jsText.addEventListener("dblclick", e => {
const style = document.createElement("style");
style.innerHTML = "* { font-family: 'Comic Sans MS', 'Comic Sans', 'Noto Sans Mono', monospace !important; }";
document.head.appendChild(style);
})
</script>
+29
View File
@@ -0,0 +1,29 @@
<script>
function toggleMenu() {
const x = document.getElementById("topnav-items");
console.log(x);
if (x.className === "topnav-list") {
x.className += " navopen";
} else {
x.className = "topnav-list";
}
}
</script>
<section class="container">
<nav class="topnav">
<a class="topnav-title" href="{{ .Site.BaseURL }}">
{{ .Site.Title }}
</a>
{{ with .Site.Menus.main }}
<a href="javascript:void(0);" class="mobilemenu" onclick="toggleMenu()">Menu</a>
<ul id="topnav-items" class="topnav-list">
{{ range sort . }}
<li class="topnav-item">
<a class="topnav-link" href="{{ .URL }}">{{ .Name }}</a>
</li>
{{ end }}
</ul>
{{ end }}
</nav>
</section>
+21
View File
@@ -0,0 +1,21 @@
<section class="container list">
<h1 class="title">{{ .Title }}</h1>
<p>Posts are also available as an <a href="/blog/index.xml" target="_blank">RSS feed</a>.</p>
<p style="text-align: center;">
<b>Categories: </b>
<a href="/blog"><i>all</i></a>
<br />
{{ $categories := slice }}
{{ range site.Taxonomies.categories.Alphabetical }}
{{ $term := .Term }}
{{ $url := printf "/categories/%s/" $term | urlize }}
{{ $categories = $categories | append (printf `<a href="%s">%s</a>` $url $term) }}
{{ end }}
{{ delimit $categories " | " | safeHTML }}
</p>
<ul>
{{ range .Pages }}
<li><span>{{ .Date.Format "January 2, 2006" }}</span><a href="{{ .Permalink }}">{{ .Title }}</a></li>
{{ end }}
</ul>
</section>
+9
View File
@@ -0,0 +1,9 @@
<section class="container page">
<article>
<header>
<h1>{{ .Title }}</h1>
</header>
{{ .Content }}
</article>
</section>
+60
View File
@@ -0,0 +1,60 @@
<section class="container">
<article class="blogpost">
<header>
<h1 class="title">{{ .Title }}</h1>
<h2 class="date">{{ .Date.Format "January 2, 2006" }}</h2>
<p style="margin-bottom: 0">
<b>Reading time</b>:
{{ if eq .ReadingTime 1 }}
1 minute
{{ else }}
{{ .ReadingTime }} minutes
{{ end }}
</p>
<p style="margin-bottom: 0; margin-top: 0;">
{{ with .GetTerms "categories" }}
<b>Categories:</b>
{{ $categories := slice }}
{{ range . }}
{{ $categories = $categories | append (printf `<a href="%s">%s</a>` .RelPermalink .LinkTitle) }}
{{ end }}
{{ delimit $categories ", " | safeHTML }}
{{ end }}
</p>
<p style="margin-top: 0">
{{ with .GetTerms "tags" }}
<b>Tags:</b>
{{ $tags := slice }}
{{ range . }}
{{ $tags = $tags | append (printf `<a href="%s">%s</a>` .RelPermalink .LinkTitle) }}
{{ end }}
{{ delimit $tags ", " | safeHTML }}
{{ end }}
</p>
</header>
<div class="post-content">
{{ .Content }}
</div>
<hr class="endofpost" />
<p style="text-align: center;">
<i>
Thoughts or questions about this article?
<br />
Feel free to drop me a
<a href="https://www.linkedin.com/in/lucianonooijen/" target="_blank">message on LinkedIn</a>
or a
<a href="https://x.com/LucianoNooijen">Twitter DM</a>!
<br />
<br />
If you like my articles, please consider subscribing to my <a href="/blog/index.xml" target="_blank">RSS feed</a>.
</i>
</p>
<hr class="endofpost" />
</article>
</section>
+7
View File
@@ -0,0 +1,7 @@
{{ define "title" }}
{{ .Title }} ·
{{ .Site.Title }}
{{ end }}
{{ define "content" }}
{{ partial "post.html" . }}
{{ end }}
+2
View File
@@ -0,0 +1,2 @@
{{ $file := .Get "file" }}
{{- $file | readFile | markdownify -}}
+565
View File
@@ -0,0 +1,565 @@
{
"name": "lucianonooijen",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lucianonooijen",
"devDependencies": {
"prettier": "^3.5.1",
"prettier-plugin-go-template": "^0.0.15",
"sass": "^1.85.0"
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
"integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^1.0.3",
"is-glob": "^4.0.3",
"micromatch": "^4.0.5",
"node-addon-api": "^7.0.0"
},
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"@parcel/watcher-android-arm64": "2.5.1",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-freebsd-x64": "2.5.1",
"@parcel/watcher-linux-arm-glibc": "2.5.1",
"@parcel/watcher-linux-arm-musl": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
"@parcel/watcher-linux-arm64-musl": "2.5.1",
"@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-linux-x64-musl": "2.5.1",
"@parcel/watcher-win32-arm64": "2.5.1",
"@parcel/watcher-win32-ia32": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1"
}
},
"node_modules/@parcel/watcher-android-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz",
"integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz",
"integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz",
"integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-freebsd-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz",
"integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz",
"integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz",
"integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz",
"integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz",
"integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz",
"integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz",
"integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz",
"integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-ia32": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz",
"integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz",
"integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/detect-libc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
"integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"bin": {
"detect-libc": "bin/detect-libc.js"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/immutable": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz",
"integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==",
"dev": true,
"license": "MIT"
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/prettier": {
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.1.tgz",
"integrity": "sha512-hPpFQvHwL3Qv5AdRvBFMhnKo4tYxp0ReXiPn2bxkiohEX6mBeBwEpBSQTkD458RaaDKQMYSp4hX4UtfUTA5wDw==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prettier-plugin-go-template": {
"version": "0.0.15",
"resolved": "https://registry.npmjs.org/prettier-plugin-go-template/-/prettier-plugin-go-template-0.0.15.tgz",
"integrity": "sha512-WqU92E1NokWYNZ9mLE6ijoRg6LtIGdLMePt2C7UBDjXeDH9okcRI3zRqtnWR4s5AloiqyvZ66jNBAa9tmRY5EQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ulid": "^2.3.0"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"prettier": "^3.0.0"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/sass": {
"version": "1.85.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.85.0.tgz",
"integrity": "sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
"source-map-js": ">=0.6.2 <2.0.0"
},
"bin": {
"sass": "sass.js"
},
"engines": {
"node": ">=14.0.0"
},
"optionalDependencies": {
"@parcel/watcher": "^2.4.1"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/ulid": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/ulid/-/ulid-2.3.0.tgz",
"integrity": "sha512-keqHubrlpvT6G2wH0OEfSW4mquYRcbe/J8NMmveoQOjUqmo+hXtO+ORCpWhdbZ7k72UtY61BL7haGxW6enBnjw==",
"dev": true,
"license": "MIT",
"bin": {
"ulid": "bin/cli.js"
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "lucianonooijen",
"description": "development only tools",
"private": true,
"repository": {
"type": "git",
"url": "git+https://github.com/lucianonooijen/Personal-Website.git"
},
"scripts": {
"dev:css": "sass ./assets/styles/style.scss ./static/css/styles.css --no-source-map --watch",
"build:css": "sass ./assets/styles/style.scss ./static/css/styles.css --no-source-map",
"fmt": "prettier -w ."
},
"devDependencies": {
"prettier": "^3.5.1",
"prettier-plugin-go-template": "^0.0.15",
"sass": "^1.85.0"
}
}
+784
View File
@@ -0,0 +1,784 @@
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
/* Document
========================================================================== */
/**
* 1. Correct the line height in all browsers.
* 2. Prevent adjustments of font size after orientation changes in iOS.
*/
html {
line-height: 1.15; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
}
/* Sections
========================================================================== */
/**
* Remove the margin in all browsers.
*/
body {
margin: 0;
}
/**
* Render the `main` element consistently in IE.
*/
main {
display: block;
}
/**
* Correct the font size and margin on `h1` elements within `section` and
* `article` contexts in Chrome, Firefox, and Safari.
*/
h1 {
font-size: 2em;
margin: 0.67em 0;
}
/* Grouping content
========================================================================== */
/**
* 1. Add the correct box sizing in Firefox.
* 2. Show the overflow in Edge and IE.
*/
hr {
box-sizing: content-box; /* 1 */
height: 0; /* 1 */
overflow: visible; /* 2 */
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
pre {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/* Text-level semantics
========================================================================== */
/**
* Remove the gray background on active links in IE 10.
*/
a {
background-color: transparent;
}
/**
* 1. Remove the bottom border in Chrome 57-
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
*/
abbr[title] {
border-bottom: none; /* 1 */
text-decoration: underline; /* 2 */
text-decoration: underline dotted; /* 2 */
}
/**
* Add the correct font weight in Chrome, Edge, and Safari.
*/
b,
strong {
font-weight: bolder;
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/**
* Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/**
* Prevent `sub` and `sup` elements from affecting the line height in
* all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/* Embedded content
========================================================================== */
/**
* Remove the border on images inside links in IE 10.
*/
img {
border-style: none;
}
/* Forms
========================================================================== */
/**
* 1. Change the font styles in all browsers.
* 2. Remove the margin in Firefox and Safari.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit; /* 1 */
font-size: 100%; /* 1 */
line-height: 1.15; /* 1 */
margin: 0; /* 2 */
}
/**
* Show the overflow in IE.
* 1. Show the overflow in Edge.
*/
button,
input {
/* 1 */
overflow: visible;
}
/**
* Remove the inheritance of text transform in Edge, Firefox, and IE.
* 1. Remove the inheritance of text transform in Firefox.
*/
button,
select {
/* 1 */
text-transform: none;
}
/**
* Correct the inability to style clickable types in iOS and Safari.
*/
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
/**
* Remove the inner border and padding in Firefox.
*/
button::-moz-focus-inner,
[type=button]::-moz-focus-inner,
[type=reset]::-moz-focus-inner,
[type=submit]::-moz-focus-inner {
border-style: none;
padding: 0;
}
/**
* Restore the focus styles unset by the previous rule.
*/
button:-moz-focusring,
[type=button]:-moz-focusring,
[type=reset]:-moz-focusring,
[type=submit]:-moz-focusring {
outline: 1px dotted ButtonText;
}
/**
* Correct the padding in Firefox.
*/
fieldset {
padding: 0.35em 0.75em 0.625em;
}
/**
* 1. Correct the text wrapping in Edge and IE.
* 2. Correct the color inheritance from `fieldset` elements in IE.
* 3. Remove the padding so developers are not caught out when they zero out
* `fieldset` elements in all browsers.
*/
legend {
box-sizing: border-box; /* 1 */
color: inherit; /* 2 */
display: table; /* 1 */
max-width: 100%; /* 1 */
padding: 0; /* 3 */
white-space: normal; /* 1 */
}
/**
* Add the correct vertical alignment in Chrome, Firefox, and Opera.
*/
progress {
vertical-align: baseline;
}
/**
* Remove the default vertical scrollbar in IE 10+.
*/
textarea {
overflow: auto;
}
/**
* 1. Add the correct box sizing in IE 10.
* 2. Remove the padding in IE 10.
*/
[type=checkbox],
[type=radio] {
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
}
/**
* Correct the cursor style of increment and decrement buttons in Chrome.
*/
[type=number]::-webkit-inner-spin-button,
[type=number]::-webkit-outer-spin-button {
height: auto;
}
/**
* 1. Correct the odd appearance in Chrome and Safari.
* 2. Correct the outline style in Safari.
*/
[type=search] {
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/**
* Remove the inner padding in Chrome and Safari on macOS.
*/
[type=search]::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* 1. Correct the inability to style clickable types in iOS and Safari.
* 2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
/* Interactive
========================================================================== */
/*
* Add the correct display in Edge, IE 10+, and Firefox.
*/
details {
display: block;
}
/*
* Add the correct display in all browsers.
*/
summary {
display: list-item;
}
/* Misc
========================================================================== */
/**
* Add the correct display in IE 10+.
*/
template {
display: none;
}
/**
* Add the correct display in IE 10.
*/
[hidden] {
display: none;
}
*,
*:after,
*:before {
box-sizing: inherit;
}
html {
box-sizing: border-box;
font-size: 62.5%;
}
body {
display: flex;
color: #2e2b26;
background-color: #efebe0;
font-family: "Noto Sans Mono", monospace;
font-size: 1.8em;
font-weight: 400;
letter-spacing: 0.032em;
line-height: 1.8em;
}
@media screen and (max-width: 800px) {
body {
font-size: 1.4em;
line-height: 1.6em;
}
}
img {
max-width: 100%;
}
.container {
margin: 0 auto;
max-width: 120rem;
width: 100%;
padding-left: 2rem;
padding-right: 2rem;
}
.wrapper {
display: flex;
flex-direction: column;
min-height: 100vh;
width: 100%;
}
.content {
flex: 1;
margin-top: 1.6rem;
margin-bottom: 2.4rem;
}
.centered {
display: flex;
height: 100%;
align-items: center;
justify-content: center;
}
@media print {
@page {
size: auto;
margin: 0;
}
nav,
footer {
display: none !important;
}
html {
font-size: 36%;
padding: 32px;
}
.content {
margin-top: -24px;
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
color: #2e2b26;
font-weight: 600;
margin: 3.2rem 0 1.4rem 0;
letter-spacing: 0.024em;
line-height: 1.1;
}
h1 {
font-size: 2.4em;
font-weight: 700;
}
h2 {
font-size: 2em;
}
h3 {
font-size: 1.6em;
}
h4 {
font-size: 1.4em;
}
h5 {
font-size: 1.2em;
}
h6 {
font-size: 1em;
}
@media screen and (max-width: 800px) {
h1 {
font-size: 2em;
}
h2 {
font-size: 1.8em;
}
h3 {
font-size: 1.6em;
}
h4 {
font-size: 1.4em;
}
h5 {
font-size: 1.2em;
}
h6 {
font-size: 1em;
}
}
a {
font-weight: 600;
color: #8b4513;
text-decoration: none;
}
a:focus, a:hover {
color: #cd4416;
text-decoration: underline;
}
p {
margin: 1.8rem 0;
text-shadow: 0.5px 0.5px 0px rgba(0, 0, 0, 0.05);
}
blockquote:not(.alert) {
border-left: 2px solid #666;
padding-left: 1.6rem;
font-style: italic;
}
blockquote:not(.alert) p {
color: #666;
}
blockquote.alert {
padding: 0;
border: 2px solid #666;
background: #dadada;
border-radius: 1.4rem;
box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.05);
margin: 1rem -3.2rem;
}
blockquote.alert .alert-heading {
display: flex;
flex-direction: row;
margin: 0;
background-color: #666;
border-radius: 1.2rem 1.2rem 0 0;
}
blockquote.alert .alert-heading-emoji {
margin: 0.4rem 0;
width: 3.2rem;
text-align: center;
}
blockquote.alert .alert-heading-text {
margin: 0.4rem 0;
}
blockquote.alert > p {
padding: 1.6rem 3.2rem;
margin: 0;
}
@media screen and (max-width: 800px) {
blockquote.alert {
margin: 1rem -1.6rem;
}
blockquote.alert .alert-heading-emoji {
width: 2.8rem;
}
blockquote.alert > p {
padding: 1.2rem;
}
}
blockquote.alert-tip {
border: 2px solid rgb(195.3, 221.1, 196.5);
background: rgb(235.1, 243.7, 235.5);
}
blockquote.alert-tip .alert-heading {
background: rgb(195.3, 221.1, 196.5);
color: #222;
font-weight: bold;
}
blockquote.alert-note {
border: 2px solid rgb(186, 213.9, 241.5);
background: rgb(232, 241.3, 250.5);
}
blockquote.alert-note .alert-heading {
background: rgb(186, 213.9, 241.5);
color: #222;
font-weight: bold;
}
blockquote.alert-info {
border: 2px solid rgb(189.3, 201.3, 222);
background: rgb(233.1, 237.1, 244);
}
blockquote.alert-info .alert-heading {
background: rgb(189.3, 201.3, 222);
color: #222;
font-weight: bold;
}
blockquote.alert-warning {
border: 2px solid rgb(255, 233.4, 178.5);
background: rgb(255, 247.8, 229.5);
}
blockquote.alert-warning .alert-heading {
background: rgb(255, 233.4, 178.5);
color: #222;
font-weight: bold;
}
blockquote.alert-caution {
border: 2px solid rgb(255, 215.4, 178.5);
background: rgb(255, 241.8, 229.5);
}
blockquote.alert-caution .alert-heading {
background: rgb(255, 215.4, 178.5);
color: #222;
font-weight: bold;
}
blockquote.alert-danger {
border: 2px solid rgb(241.8, 192.6, 192.6);
background: rgb(250.6, 234.2, 234.2);
}
blockquote.alert-danger .alert-heading {
background: rgb(241.8, 192.6, 192.6);
color: #222;
font-weight: bold;
}
pre {
padding: 2.4rem 2rem;
white-space: pre-wrap;
word-wrap: break-word;
margin: 2.4rem -2rem;
}
@media screen and (max-width: 800px) {
pre {
margin: 2.4rem 0;
}
}
code {
color: #223244;
background-color: #dadada;
font-family: "Anonymous Pro", monospace;
font-weight: 500;
padding: 4px 8px;
}
.highlight {
line-height: 1.6;
}
.highlight code {
color: unset;
background-color: unset;
padding: 0;
font-weight: 400;
}
.topnav {
height: 60px;
display: flex;
justify-content: space-between;
align-items: center;
}
.topnav-title {
color: #2e2b26;
letter-spacing: 0;
}
.topnav-list {
list-style: none;
display: flex;
gap: 24px;
}
.topnav-link {
color: #2e2b26;
letter-spacing: 0;
}
.mobilemenu {
display: none;
}
@media screen and (max-width: 800px) {
.mobilemenu {
display: initial;
color: #2e2b26;
}
.topnav-list {
display: none;
gap: 0;
}
.topnav-list.navopen {
display: block;
position: absolute;
top: 60px;
right: 20px;
padding-left: 0 !important;
height: auto;
text-align: right;
background-color: #ddd;
display: flex;
flex-direction: column;
}
.topnav-item {
display: inline-block;
border-top: solid 1px #999;
min-width: 200px;
padding: 12px 24px;
}
.topnav-item:first-of-type {
border-top: none;
}
}
table {
border-collapse: collapse;
}
table td,
table th {
text-align: left;
border: 1px solid #666;
padding: 0.8rem 1rem;
font-size: 1.6rem;
line-height: 1.2;
}
table tr:first-child th {
border-top: 0;
}
table tr:last-child td {
border-bottom: 0;
}
table tr td:first-child,
table tr th:first-child {
border-left: 0;
}
table tr td:last-child,
table tr th:last-child {
border-right: 0;
}
.list ul {
list-style: none;
margin: 4rem 0;
padding: 0;
}
.list ul li {
display: flex;
justify-content: space-between;
align-items: center;
flex-direction: row-reverse;
padding: 0.4rem 0;
border-bottom: 1px solid #bbb;
}
.list ul li span {
display: block;
width: 100%;
max-width: 20rem;
margin-left: 3rem;
}
.list ul li a {
color: #2e2b26;
line-height: 1.1;
}
@media screen and (max-width: 800px) {
.list ul li {
flex-direction: column;
align-items: flex-start;
border-bottom: none;
padding: 0.6rem 0;
}
.list ul li span {
margin-left: 0;
font-size: 1.2rem;
}
}
article.blogpost {
max-width: 80rem;
margin-left: auto;
margin-right: auto;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased !important;
-moz-font-smoothing: antialiased !important;
text-rendering: optimizelegibility !important;
}
.post-content {
margin-top: 64px;
}
.post-content img {
width: 80%;
margin-left: 10%;
}
.post-content .highlight {
font-size: 14px;
line-height: 1.4;
}
@media screen and (max-width: 800px) {
.post-content .post-content {
margin-top: 36px;
}
.post-content .highlight {
font-size: 12px;
}
}
.endofpost {
max-width: 400px;
margin: 3em auto 3.4em;
display: block;
height: 1px;
border: 0;
border-top: 1px solid #888;
padding: 0;
}
a[href^="#fn:"]:before {
content: "[";
}
a[href^="#fn:"]:after {
content: "]";
}
.footnotes hr {
height: 1px;
border: 0;
border-top: 1px solid #888;
}
.block-experience h4 {
margin-bottom: 0.4rem;
}
.block-experience p {
margin-top: 0;
margin-bottom: 0.8rem;
}
.block-experience ul {
margin-top: 0;
margin-bottom: 2.4rem;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+7
View File
@@ -0,0 +1,7 @@
{
"build": {
"env": {
"HUGO_VERSION": "0.143.1"
}
}
}