diff options
| author | grm <grm@eyesin.space> | 2026-09-14 01:32:25 +0300 |
|---|---|---|
| committer | grm <grm@eyesin.space> | 2026-09-14 01:32:25 +0300 |
| commit | e14a4250efb828262347f5a4bbc4067cd71d885f (patch) | |
| tree | 3fcf6512f3b030b06c818ff4a224677a026d3b1b /internal/i18n | |
| parent | 282d2ab3e0fb3a74f32160a231225773001acd14 (diff) | |
| download | blogspace-e14a4250efb828262347f5a4bbc4067cd71d885f.tar.gz blogspace-e14a4250efb828262347f5a4bbc4067cd71d885f.tar.bz2 blogspace-e14a4250efb828262347f5a4bbc4067cd71d885f.zip | |
Add languages: English and Greek, chosen per blog on the Settings tab
Every blog and its dashboard were hard-wired to English. A blogger can
now pick the language of their blog; it switches the whole dashboard and
the blog's fixed text — post dates, archive months, the RSS link, the
pager, the 404 page — while what the blogger wrote is left alone.
The new internal/i18n package keys translations by the English string,
so an untranslated key renders as English rather than blank, and
TestGreekCatalogComplete scans the templates and handlers to fail when
the Greek catalog misses a key or keeps a stale one. Templates are
compiled once per language with t/tf/date/postdate/month closed over the
language, so they need no data plumbing.
The language lives in settings.language (blog migration 00002), not in
the theme, so "Reset design" does not touch it. Public pages use the
blog's language; management pages use the logged-in user's own blog's,
so a superadmin editing someone else's blog keeps theirs; the login page
follows Accept-Language.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
Diffstat (limited to 'internal/i18n')
| -rw-r--r-- | internal/i18n/el.go | 429 | ||||
| -rw-r--r-- | internal/i18n/el_months.go | 7 | ||||
| -rw-r--r-- | internal/i18n/i18n.go | 110 | ||||
| -rw-r--r-- | internal/i18n/i18n_test.go | 63 |
4 files changed, 609 insertions, 0 deletions
diff --git a/internal/i18n/el.go b/internal/i18n/el.go new file mode 100644 index 0000000..55b6cf6 --- /dev/null +++ b/internal/i18n/el.go @@ -0,0 +1,429 @@ +package i18n + +// el is the Greek catalog, keyed by the English text. TestGreekCatalogComplete +// (package web) fails when a key used by a template or handler is missing here, +// or when an entry is no longer used. +var el = map[string]string{ + // ---- dashboard chrome ---- + "superadmin": "υπερδιαχειριστής", + "blogger": "συντάκτης", + "Admin": "Διαχείριση", + "Password": "Κωδικός", + "Log out": "Αποσύνδεση", + "Log in": "Σύνδεση", + "Username": "Όνομα χρήστη", + "Overview": "Επισκόπηση", + "Posts": "Δημοσιεύσεις", + "Pages": "Σελίδες", + "Announcements": "Ανακοινώσεις", + "Layout": "Διάταξη", + "Design": "Σχεδίαση", + "Images": "Εικόνες", + "Settings": "Ρυθμίσεις", + "View blog": "Προβολή ιστολογίου", + "View page": "Προβολή σελίδας", + "View post": "Προβολή δημοσίευσης", + "view": "προβολή", + "edit": "επεξεργασία", + "delete": "διαγραφή", + "remove": "αφαίρεση", + "Remove": "Αφαίρεση", + "Save": "Αποθήκευση", + "Add": "Προσθήκη", + "Back": "Πίσω", + "Cancel": "Άκυρο", + "Create": "Δημιουργία", + "Set": "Ορισμός", + "Hide": "Απόκρυψη", + "Show": "Εμφάνιση", + "Filter": "Φιλτράρισμα", + "Upload": "Μεταφόρτωση", + "Confirm": "Επιβεβαίωση", + "Move up": "Μετακίνηση πάνω", + "Move down": "Μετακίνηση κάτω", + "Order": "Σειρά", + "Title": "Τίτλος", + "Page": "Σελίδα", + "Page:": "Σελίδα:", + "Date": "Ημερομηνία", + "Address": "Διεύθυνση", + "Address:": "Διεύθυνση:", + "Text": "Κείμενο", + "Content": "Περιεχόμενο", + "Heading": "Επικεφαλίδα", + "hidden": "κρυφό", + "home": "αρχική", + "optional": "προαιρετικό", + "untitled": "χωρίς τίτλο", + "you": "εσείς", + "tab.": "καρτέλα.", + "your blog": "το ιστολόγιό σας", + "to see it.": "για να τη δείτε.", + "to see them.": "για να τις δείτε.", + + // ---- overview ---- + "Your blog is live at": "Το ιστολόγιό σας είναι δημόσιο στη διεύθυνση", + "Every change you save here shows up there as soon as you refresh.": "Κάθε αλλαγή που αποθηκεύετε εδώ εμφανίζεται εκεί μόλις ανανεώσετε τη σελίδα.", + "Quick actions": "Γρήγορες ενέργειες", + "Write a new post": "Γράψτε νέα δημοσίευση", + "Add a page": "Προσθήκη σελίδας", + "Post an announcement": "Νέα ανακοίνωση", + "Arrange the layout": "Ρύθμιση διάταξης", + "Change the look": "Αλλαγή εμφάνισης", + "Upload images": "Μεταφόρτωση εικόνων", + "%d posts": "%d δημοσιεύσεις", + "hidden from menu": "εκτός μενού", + "Latest posts": "Τελευταίες δημοσιεύσεις", + "All posts": "Όλες οι δημοσιεύσεις", + "No posts yet.": "Δεν υπάρχουν δημοσιεύσεις ακόμα.", + "Write the first one.": "Γράψτε την πρώτη.", + + // ---- posts ---- + "New post": "Νέα δημοσίευση", + "Edit post": "Επεξεργασία δημοσίευσης", + "All pages": "Όλες οι σελίδες", + "No posts here yet.": "Δεν υπάρχουν δημοσιεύσεις εδώ ακόμα.", + "optional, made from the title": "προαιρετικό, φτιάχνεται από τον τίτλο", + "Visible on the blog": "Ορατή στο ιστολόγιο", + "Back to posts": "Πίσω στις δημοσιεύσεις", + "Post deleted.": "Η δημοσίευση διαγράφηκε.", + "the post “%s”": "τη δημοσίευση «%s»", + "Title is required (max 200 characters).": "Ο τίτλος είναι υποχρεωτικός (έως 200 χαρακτήρες).", + "Slug may only contain lowercase letters, digits and dashes.": "Η διεύθυνση επιτρέπει μόνο πεζά λατινικά γράμματα, ψηφία και παύλες.", + "Pick a page for this post.": "Επιλέξτε σελίδα για τη δημοσίευση.", + "Post is too long (200 KB max).": "Η δημοσίευση είναι πολύ μεγάλη (έως 200 KB).", + "A post with that slug already exists on this page; choose another slug.": "Υπάρχει ήδη δημοσίευση με αυτή τη διεύθυνση στη σελίδα· διαλέξτε άλλη.", + "Saved. Refresh your blog to see it.": "Αποθηκεύτηκε. Ανανεώστε το ιστολόγιό σας για να το δείτε.", + "Image not added:": "Η εικόνα δεν προστέθηκε:", + + // ---- pages ---- + "New page": "Νέα σελίδα", + "Edit page": "Επεξεργασία σελίδας", + "Each page holds its own list of posts. Which pages are in the menu, and in what order, is set on the": "Κάθε σελίδα έχει τη δική της λίστα δημοσιεύσεων. Ποιες σελίδες μπαίνουν στο μενού, και με ποια σειρά, ορίζεται στην", + "not in menu": "εκτός μενού", + "Make home": "Ορισμός ως αρχικής", + "leave empty to make one from the title; e.g.": "αφήστε το κενό για να φτιαχτεί από τον τίτλο· π.χ.", + "Intro text": "Εισαγωγικό κείμενο", + "Markdown, shown above the posts; optional": "Markdown, εμφανίζεται πάνω από τις δημοσιεύσεις· προαιρετικό", + "Show in menu": "Εμφάνιση στο μενού", + "Back to pages": "Πίσω στις σελίδες", + "Page deleted.": "Η σελίδα διαγράφηκε.", + "“%s” is now the home page.": "Η «%s» είναι τώρα η αρχική σελίδα.", + "the page “%s” and all %d of its posts": "τη σελίδα «%s» και όλες τις %d δημοσιεύσεις της", + "Title is required (max 120 characters).": "Ο τίτλος είναι υποχρεωτικός (έως 120 χαρακτήρες).", + "Slug may only contain lowercase letters, digits and dashes (and not be a reserved word).": "Η διεύθυνση επιτρέπει μόνο πεζά λατινικά γράμματα, ψηφία και παύλες (και δεν μπορεί να είναι δεσμευμένη λέξη).", + "A page with that slug already exists.": "Υπάρχει ήδη σελίδα με αυτή τη διεύθυνση.", + "The home page cannot be deleted. Make another page the home page first.": "Η αρχική σελίδα δεν διαγράφεται. Ορίστε πρώτα άλλη σελίδα ως αρχική.", + "This is the home page; it cannot be deleted. Make another page the home page first.": "Αυτή είναι η αρχική σελίδα και δεν διαγράφεται. Ορίστε πρώτα άλλη σελίδα ως αρχική.", + + // ---- confirm ---- + "Are you sure?": "Είστε σίγουροι;", + "You are about to permanently delete %s. This cannot be undone.": "Πρόκειται να διαγράψετε οριστικά %s. Δεν υπάρχει αναίρεση.", + "Yes, delete": "Ναι, διαγραφή", + + // ---- announcements ---- + "New announcement": "Νέα ανακοίνωση", + "Edit announcement": "Επεξεργασία ανακοίνωσης", + "Announcement": "Ανακοίνωση", + "Notices shown on every page and post of your blog — the next meeting, this month's book, a holiday closure. Hide one instead of deleting it if you will need it again.": "Ειδοποιήσεις που εμφανίζονται σε κάθε σελίδα και δημοσίευση του ιστολογίου — η επόμενη συνάντηση, το βιβλίο του μήνα, οι διακοπές. Αν θα την ξαναχρειαστείτε, κρύψτε την αντί να τη διαγράψετε.", + "Where": "Πού", + "Look": "Εμφάνιση", + "Position": "Θέση", + "top": "πάνω", + "bottom": "κάτω", + "At the top": "Στην κορυφή", + "At the bottom": "Στο τέλος", + "Main content": "Κύριο περιεχόμενο", + "Main content (with the posts)": "Κύριο περιεχόμενο (μαζί με τις δημοσιεύσεις)", + "Left column": "Αριστερή στήλη", + "Right column": "Δεξιά στήλη", + "Highlighted": "Τονισμένη", + "Warning": "Προειδοποίηση", + "Plain": "Απλή", + "Next meeting": "Επόμενη συνάντηση", + "“Top” of a side column means above its modules, “bottom” below them. If that column is not shown on your blog (see": "«Κορυφή» μιας πλαϊνής στήλης σημαίνει πάνω από τα στοιχεία της, «τέλος» κάτω από αυτά. Αν η στήλη δεν εμφανίζεται στο ιστολόγιό σας (βλ.", + "the announcement moves to the main content instead.": "η ανακοίνωση μεταφέρεται στο κύριο περιεχόμενο.", + "Shown on the blog": "Εμφανίζεται στο ιστολόγιο", + "Back to announcements": "Πίσω στις ανακοινώσεις", + "No announcements yet.": "Δεν υπάρχουν ανακοινώσεις ακόμα.", + "Announcement deleted.": "Η ανακοίνωση διαγράφηκε.", + "Announcement hidden.": "Η ανακοίνωση κρύφτηκε.", + "Announcement shown. Refresh your blog to see it.": "Η ανακοίνωση εμφανίζεται. Ανανεώστε το ιστολόγιό σας για να τη δείτε.", + "this announcement": "αυτή την ανακοίνωση", + "the announcement “%s”": "την ανακοίνωση «%s»", + "Title is too long (max 120 characters).": "Ο τίτλος είναι πολύ μεγάλος (έως 120 χαρακτήρες).", + "Write some text for the announcement.": "Γράψτε κάποιο κείμενο για την ανακοίνωση.", + "Announcement is too long (20 KB max).": "Η ανακοίνωση είναι πολύ μεγάλη (έως 20 KB).", + + // ---- images ---- + "Images are stored with your blog. Put one in a post with": "Οι εικόνες αποθηκεύονται μαζί με το ιστολόγιό σας. Βάλτε μία σε δημοσίευση με το", + "in the editor, or copy its line from below.": "στον επεξεργαστή κειμένου, ή αντιγράψτε τη γραμμή της από παρακάτω.", + "Upload an image": "Μεταφόρτωση εικόνας", + "PNG, JPEG, GIF, WebP or ICO": "PNG, JPEG, GIF, WebP ή ICO", + "No images yet.": "Δεν υπάρχουν εικόνες ακόμα.", + "Uploaded %s.": "Η εικόνα %s μεταφορτώθηκε.", + "Image deleted.": "Η εικόνα διαγράφηκε.", + "Choose a file first.": "Επιλέξτε πρώτα ένα αρχείο.", + "Upload too large or malformed form.": "Η μεταφόρτωση είναι πολύ μεγάλη ή η φόρμα ελαττωματική.", + "Image is too large (max %s).": "Η εικόνα είναι πολύ μεγάλη (έως %s).", + "Only PNG, JPEG, GIF, WebP and ICO images are accepted.": "Γίνονται δεκτές μόνο εικόνες PNG, JPEG, GIF, WebP και ICO.", + + // ---- editor ---- + "Insert image": "Εισαγωγή εικόνας", + "…or paste / drop an image into the text.": "…ή επικολλήστε / σύρετε μια εικόνα μέσα στο κείμενο.", + "The image is added at the end of the text when you save.": "Η εικόνα προστίθεται στο τέλος του κειμένου όταν αποθηκεύσετε.", + "Uploading": "Μεταφόρτωση", + "upload failed": "η μεταφόρτωση απέτυχε", + "Inserted": "Προστέθηκε η", + "Could not upload:": "Αποτυχία μεταφόρτωσης:", + "Formatting cheat-sheet": "Οδηγός μορφοποίησης", + "Smaller heading": "Μικρότερη επικεφαλίδα", + "bold": "έντονα", + "italic": "πλάγια", + "link text": "κείμενο συνδέσμου", + "description": "περιγραφή", + "writes this for you": "το γράφει για εσάς", + "list item": "στοιχείο λίστας", + "numbered": "αριθμημένο", + "quote": "παράθεση", + "code": "κώδικας", + "for a line": "για γραμμή", + "Blank line = new paragraph. Press Enter once for a line break.": "Κενή γραμμή = νέα παράγραφος. Ένα Enter για αλλαγή γραμμής.", + + // ---- settings ---- + "Blog settings": "Ρυθμίσεις ιστολογίου", + "Blog title": "Τίτλος ιστολογίου", + "Tagline": "Υπότιτλος", + "shown under the title": "εμφανίζεται κάτω από τον τίτλο", + "Language": "Γλώσσα", + "of the dashboard, dates and the blog's fixed text": "του πίνακα διαχείρισης, των ημερομηνιών και του σταθερού κειμένου του ιστολογίου", + "only the administrator can change this": "μόνο ο διαχειριστής μπορεί να την αλλάξει", + "Title is required (max 120 chars); tagline max 300.": "Ο τίτλος είναι υποχρεωτικός (έως 120 χαρακτήρες)· ο υπότιτλος έως 300.", + + // ---- password ---- + "Change password": "Αλλαγή κωδικού", + "Current password": "Τρέχων κωδικός", + "New password": "Νέος κωδικός", + "Repeat new password": "Επανάληψη νέου κωδικού", + "Current password is wrong.": "Ο τρέχων κωδικός είναι λάθος.", + "New password must be at least 8 characters.": "Ο νέος κωδικός πρέπει να έχει τουλάχιστον 8 χαρακτήρες.", + "New passwords do not match.": "Οι νέοι κωδικοί δεν ταιριάζουν.", + "Password changed.": "Ο κωδικός άλλαξε.", + "Wrong username or password.": "Λάθος όνομα χρήστη ή κωδικός.", + + // ---- layout ---- + "Decide what goes where on your blog. Every change here is saved straight away; refresh": "Αποφασίστε τι πάει πού στο ιστολόγιό σας. Κάθε αλλαγή εδώ αποθηκεύεται αμέσως· ανανεώστε", + "Colours and fonts are on the": "Τα χρώματα και οι γραμματοσειρές είναι στην", + "Columns": "Στήλες", + "Header": "Κεφαλίδα", + "Footer": "Υποσέλιδο", + "Menu": "Μενού", + "Left": "Αριστερά", + "Right": "Δεξιά", + "Left column width": "Πλάτος αριστερής στήλης", + "Right column width": "Πλάτος δεξιάς στήλης", + "Between 15% and 40% each; the main content takes the rest. A column only appears when it has at least one module; without columns the posts take the whole width. On phones the columns stack: main content first, then the left and right columns, then the footer.": "Από 15% έως 40% η καθεμία· το κύριο περιεχόμενο παίρνει το υπόλοιπο. Μια στήλη εμφανίζεται μόνο όταν έχει τουλάχιστον ένα στοιχείο· χωρίς στήλες οι δημοσιεύσεις παίρνουν όλο το πλάτος. Στα κινητά οι στήλες στοιβάζονται: πρώτα το κύριο περιεχόμενο, μετά η αριστερή και η δεξιά στήλη, μετά το υποσέλιδο.", + "Always keep the columns' space, even when a column is empty or hidden": "Να κρατιέται πάντα ο χώρος των στηλών, ακόμα κι όταν μια στήλη είναι άδεια ή κρυφή", + "the main content then keeps its width and stays centred": "το κύριο περιεχόμενο κρατά έτσι το πλάτος του και μένει κεντραρισμένο", + "Save columns": "Αποθήκευση στηλών", + "The top of every page. Stack the title, a logo and the menu in the order you like. A banner image can be chosen on the Design tab.": "Η κορυφή κάθε σελίδας. Βάλτε τον τίτλο, ένα λογότυπο και το μενού με όποια σειρά θέλετε. Μια εικόνα-πανό επιλέγεται στην καρτέλα Σχεδίαση.", + "A narrow column beside your posts. Good for the menu, an archive or recent posts.": "Μια στενή στήλη δίπλα στις δημοσιεύσεις σας. Καλή για το μενού, το αρχείο ή τις πρόσφατες δημοσιεύσεις.", + "A narrow column on the other side of your posts. Same choices as the left one.": "Μια στενή στήλη από την άλλη πλευρά των δημοσιεύσεων. Ίδιες επιλογές με την αριστερή.", + "The bottom of every page: a copyright line, the RSS link, a list of your pages.": "Το τέλος κάθε σελίδας: μια γραμμή copyright, ο σύνδεσμος RSS, μια λίστα με τις σελίδες σας.", + "Your posts and pages always show here. You can add your own HTML before and after them — an intro box, a newsletter form, a badge…": "Οι δημοσιεύσεις και οι σελίδες σας εμφανίζονται πάντα εδώ. Μπορείτε να προσθέσετε δικό σας HTML πριν και μετά — ένα εισαγωγικό πλαίσιο, μια φόρμα newsletter, ένα σήμα…", + "Nothing extra.": "Τίποτα επιπλέον.", + "Add custom HTML": "Προσθήκη δικού σας HTML", + "The links the Menu module shows, wherever you place it. Pages and outside links can be mixed in any order. Ticking “Show in menu” on a page does the same as adding it here.": "Οι σύνδεσμοι που δείχνει το στοιχείο Μενού, όπου κι αν το βάλετε. Σελίδες και εξωτερικοί σύνδεσμοι μπορούν να αναμειχθούν με όποια σειρά θέλετε. Το «Εμφάνιση στο μενού» σε μια σελίδα κάνει το ίδιο με την προσθήκη της εδώ.", + "page": "σελίδα", + "link": "σύνδεσμος", + "The menu is empty.": "Το μενού είναι άδειο.", + "Add page": "Προσθήκη σελίδας", + "Every page is already in the menu.": "Όλες οι σελίδες είναι ήδη στο μενού.", + "Add a link": "Προσθήκη συνδέσμου", + "Add link": "Προσθήκη συνδέσμου", + "My photos": "Οι φωτογραφίες μου", + "Hide this area": "Απόκρυψη περιοχής", + "Show this area": "Εμφάνιση περιοχής", + "no logo chosen": "δεν έχει επιλεγεί λογότυπο", + "pick one on the": "επιλέξτε ένα στην", + "Nothing here yet.": "Τίποτα εδώ ακόμα.", + "Nothing here yet — this area is not shown until you add a module.": "Τίποτα εδώ ακόμα — η περιοχή δεν εμφανίζεται μέχρι να προσθέσετε ένα στοιχείο.", + "Add a module": "Προσθήκη στοιχείου", + "Columns saved. Refresh your blog to see them.": "Οι στήλες αποθηκεύτηκαν. Ανανεώστε το ιστολόγιό σας για να τις δείτε.", + "%s hidden.": "%s: κρύφτηκε.", + "%s shown again. Refresh your blog to see it.": "%s: εμφανίζεται ξανά. Ανανεώστε το ιστολόγιό σας για να τη δείτε.", + "That module cannot go in that area.": "Αυτό το στοιχείο δεν μπορεί να μπει σε αυτή την περιοχή.", + "There is already a module there; edit or remove it first.": "Υπάρχει ήδη στοιχείο εκεί· επεξεργαστείτε το ή αφαιρέστε το πρώτα.", + "Added. Now write what it should show.": "Προστέθηκε. Γράψτε τώρα τι θα δείχνει.", + "%s added. Refresh your blog to see it.": "%s: προστέθηκε. Ανανεώστε το ιστολόγιό σας για να το δείτε.", + "%s removed.": "%s: αφαιρέθηκε.", + "the “%s” module and its text": "το στοιχείο «%s» και το κείμενό του", + "Pick a page first.": "Επιλέξτε πρώτα μια σελίδα.", + "Page added to the menu. Refresh your blog to see it.": "Η σελίδα προστέθηκε στο μενού. Ανανεώστε το ιστολόγιό σας για να τη δείτε.", + "Give the link a short text (max 60 characters).": "Δώστε στον σύνδεσμο ένα σύντομο κείμενο (έως 60 χαρακτήρες).", + "That address does not look right. Use a full web address like https://example.org or a path like /about.": "Η διεύθυνση δεν φαίνεται σωστή. Χρησιμοποιήστε πλήρη διεύθυνση όπως https://example.org ή διαδρομή όπως /about.", + "Link added to the menu. Refresh your blog to see it.": "Ο σύνδεσμος προστέθηκε στο μενού. Ανανεώστε το ιστολόγιό σας για να τον δείτε.", + "Removed from the menu.": "Αφαιρέθηκε από το μενού.", + + // module and area names (web.moduleNames / areaNames) + "Blog title & tagline": "Τίτλος & υπότιτλος ιστολογίου", + "Logo": "Λογότυπο", + "Archive (posts by month)": "Αρχείο (δημοσιεύσεις ανά μήνα)", + "Recent posts": "Πρόσφατες δημοσιεύσεις", + "Custom HTML": "Δικό σας HTML", + "RSS link": "Σύνδεσμος RSS", + "Footer text": "Κείμενο υποσέλιδου", + "Site map": "Χάρτης ιστολογίου", + "Before the posts": "Πριν τις δημοσιεύσεις", + "After the posts": "Μετά τις δημοσιεύσεις", + "Archive": "Αρχείο", + "empty": "κενό", + "heading “%s”": "επικεφαλίδα «%s»", + + // ---- module form ---- + "in the %s": "— %s", + "How many posts": "Πόσες δημοσιεύσεις", + "The newest published posts from every page of your blog, newest first.": "Οι νεότερες δημοσιεύσεις από όλες τις σελίδες του ιστολογίου, με τις πιο πρόσφατες πρώτα.", + "Every published post grouped by year and month, newest first. Years and months fold open and shut; the latest year starts open.": "Όλες οι δημοσιεύσεις ανά έτος και μήνα, με τις πιο πρόσφατες πρώτα. Έτη και μήνες ανοιγοκλείνουν· το τελευταίο έτος ξεκινά ανοιχτό.", + "Links to every page of your blog.": "Σύνδεσμοι προς όλες τις σελίδες του ιστολογίου.", + "Hello!": "Γεια σας!", + "Put into your page exactly as written — embeds, badges and scripts all work. Anything broken here (an unclosed tag, say) can break the page around it, so check your blog after saving. 20 KB at most.": "Μπαίνει στη σελίδα σας ακριβώς όπως γράφεται — ενσωματώσεις, σήματα και scripts δουλεύουν όλα. Ό,τι είναι λάθος εδώ (π.χ. μια ετικέτα που δεν κλείνει) μπορεί να χαλάσει τη σελίδα γύρω του, γι' αυτό ελέγξτε το ιστολόγιό σας μετά την αποθήκευση. Έως 20 KB.", + "© 2026 Me": "© 2026 Εγώ", + "Plain text; each line becomes its own paragraph.": "Απλό κείμενο· κάθε γραμμή γίνεται ξεχωριστή παράγραφος.", + "Back to layout": "Πίσω στη διάταξη", + "Heading is too long (max 120 characters).": "Η επικεφαλίδα είναι πολύ μεγάλη (έως 120 χαρακτήρες).", + "Content is too long (20 KB max).": "Το περιεχόμενο είναι πολύ μεγάλο (έως 20 KB).", + + // ---- design ---- + "Changes are live as soon as you save; refresh": "Οι αλλαγές ισχύουν μόλις αποθηκεύσετε· ανανεώστε", + "Schemes": "Συνδυασμοί", + "Background": "Φόντο", + "Logo & icon": "Λογότυπο & εικονίδιο", + "What appears where — header, columns, footer and their modules — is arranged on the": "Τι εμφανίζεται πού — κεφαλίδα, στήλες, υποσέλιδο και τα στοιχεία τους — ρυθμίζεται στην", + "Colour schemes": "Χρωματικοί συνδυασμοί", + "A quick start: applying a scheme sets every colour below and saves immediately. Images, fonts and layout are kept.": "Για γρήγορη αρχή: ένας συνδυασμός ορίζει όλα τα παρακάτω χρώματα και αποθηκεύεται αμέσως. Εικόνες, γραμματοσειρές και διάταξη διατηρούνται.", + "Apply %s": "Εφαρμογή: %s", + "Paper": "Χαρτί", + "Night": "Νύχτα", + "Ocean": "Ωκεανός", + "Forest": "Δάσος", + "Slate": "Σχιστόλιθος", + "Rose": "Ρόδο", + "Colour": "Χρώμα", + "Image style": "Στυλ εικόνας", + "Stretch to fill": "Τέντωμα ώστε να γεμίζει", + "Fill, fixed while scrolling": "Γέμισμα, σταθερό στην κύλιση", + "Repeat (tile)": "Επανάληψη (πλακάκι)", + "Background image": "Εικόνα φόντου", + "Text & content area": "Κείμενο & περιοχή περιεχομένου", + "Content background": "Φόντο περιεχομένου", + "Text colour": "Χρώμα κειμένου", + "Link colour": "Χρώμα συνδέσμων", + "Change colour when the mouse is over a link": "Αλλαγή χρώματος όταν το ποντίκι είναι πάνω από σύνδεσμο", + "Hover colour": "Χρώμα στο πέρασμα", + "Font": "Γραμματοσειρά", + "Heading font": "Γραμματοσειρά επικεφαλίδων", + "Same as text": "Ίδια με το κείμενο", + "Text size": "Μέγεθος κειμένου", + "Small": "Μικρό", + "Normal": "Κανονικό", + "Large": "Μεγάλο", + "Underline links": "Υπογράμμιση συνδέσμων", + "Always": "Πάντα", + "Only on hover": "Μόνο στο πέρασμα", + "Never": "Ποτέ", + "Box style": "Στυλ πλαισίου", + "Card (shadow, rounded corners)": "Κάρτα (σκιά, στρογγυλεμένες γωνίες)", + "Flat": "Επίπεδο", + "Padding": "Περιθώριο", + "inside the box": "μέσα στο πλαίσιο", + "Compact": "Σφιχτό", + "Roomy": "Άνετο", + "The content spans the whole page; how much of it the posts get is set by the column widths on the": "Το περιεχόμενο απλώνεται σε όλη τη σελίδα· πόσο από αυτό παίρνουν οι δημοσιεύσεις ορίζεται από τα πλάτη των στηλών στην", + "Alignment": "Στοίχιση", + "Centred": "Στο κέντρο", + "Image height": "Ύψος εικόνας", + "Short": "Χαμηλό", + "Medium": "Μεσαίο", + "Tall": "Ψηλό", + "The title, logo and menu are header modules — add, remove and order them on the": "Ο τίτλος, το λογότυπο και το μενού είναι στοιχεία της κεφαλίδας — προσθέστε, αφαιρέστε και ταξινομήστε τα στην", + "The banner below is shown across the top of the header whenever one is chosen.": "Το πανό παρακάτω εμφανίζεται κατά μήκος της κορυφής της κεφαλίδας όποτε έχει επιλεγεί κάποιο.", + "Header image (banner)": "Εικόνα κεφαλίδας (πανό)", + "Which links the menu shows, and where it sits, is set on the": "Ποιους συνδέσμους δείχνει το μενού, και πού βρίσκεται, ορίζεται στην", + "Here you choose how it looks.": "Εδώ επιλέγετε πώς φαίνεται.", + "menu in the header": "μενού στην κεφαλίδα", + "Link style": "Στυλ συνδέσμων", + "Uppercase, spaced": "Κεφαλαία, αραιά", + "Menu colours": "Χρώματα μενού", + "Posts & dates": "Δημοσιεύσεις & ημερομηνίες", + "Show the date on posts": "Εμφάνιση ημερομηνίας στις δημοσιεύσεις", + "Date format": "Μορφή ημερομηνίας", + "The footer text, RSS link and other footer modules live on the": "Το κείμενο υποσέλιδου, ο σύνδεσμος RSS και τα άλλα στοιχεία του υποσέλιδου βρίσκονται στην", + "Logo & site icon": "Λογότυπο & εικονίδιο ιστολογίου", + "Logo size": "Μέγεθος λογότυπου", + "Small (48 px tall)": "Μικρό (48 px ύψος)", + "Medium (80 px)": "Μεσαίο (80 px)", + "Large (140 px)": "Μεγάλο (140 px)", + "Shown wherever you place a Logo module (Layout tab); it links to your front page.": "Εμφανίζεται όπου βάλετε στοιχείο Λογότυπο (καρτέλα Διάταξη)· οδηγεί στην αρχική σας σελίδα.", + "Favicon": "Εικονίδιο (favicon)", + "The small icon browsers show in the tab. A square PNG or ICO of 32–64 px works best; pick None for the Blogspace icon.": "Το μικρό εικονίδιο που δείχνουν οι φυλλομετρητές στην καρτέλα. Ένα τετράγωνο PNG ή ICO 32–64 px είναι ιδανικό· επιλέξτε Κανένα για το εικονίδιο του Blogspace.", + "Save design": "Αποθήκευση σχεδίασης", + "Reset to defaults": "Επαναφορά προεπιλογών", + "Puts every colour, font and layout option back to how a new blog starts and forgets the chosen images (they stay in your library).": "Επαναφέρει κάθε χρώμα, γραμματοσειρά και επιλογή διάταξης όπως ξεκινά ένα νέο ιστολόγιο και ξεχνά τις επιλεγμένες εικόνες (μένουν στη συλλογή σας).", + "Reset everything": "Επαναφορά όλων", + "None": "Κανένα", + "…or upload a new one": "…ή μεταφορτώστε καινούργια", + "Design saved. Refresh your blog to see it.": "Η σχεδίαση αποθηκεύτηκε. Ανανεώστε το ιστολόγιό σας για να τη δείτε.", + "Unknown colour scheme.": "Άγνωστος χρωματικός συνδυασμός.", + "Applied the %s scheme. Refresh your blog to see it.": "Εφαρμόστηκε ο συνδυασμός %s. Ανανεώστε το ιστολόγιό σας για να τον δείτε.", + "Design and layout reset to the defaults.": "Σχεδίαση και διάταξη επανήλθαν στις προεπιλογές.", + + // ---- admin ---- + "Users & blogs": "Χρήστες & ιστολόγια", + "New blogger": "Νέος συντάκτης", + "User": "Χρήστης", + "Role": "Ρόλος", + "Blog": "Ιστολόγιο", + "Since": "Από", + "Actions": "Ενέργειες", + "disabled": "απενεργοποιημένος", + "reset password": "επαναφορά κωδικού", + "new password": "νέος κωδικός", + "enable": "ενεργοποίηση", + "disable": "απενεργοποίηση", + "min 8 characters; they can change it later": "τουλάχιστον 8 χαρακτήρες· μπορεί να τον αλλάξει αργότερα", + "Subdomain": "Υποτομέας", + "defaults to the username": "προεπιλογή το όνομα χρήστη", + "Delete user": "Διαγραφή χρήστη", + "Delete %s?": "Διαγραφή του χρήστη %s;", + "This permanently deletes the user": "Διαγράφεται οριστικά ο χρήστης", + ", their blog, and every page, post and image in it.": ", το ιστολόγιό του και κάθε σελίδα, δημοσίευση και εικόνα σε αυτό.", + "Yes, delete everything": "Ναι, διαγραφή όλων", + "Username: 2-40 letters, digits, dots, dashes or underscores.": "Όνομα χρήστη: 2-40 λατινικά γράμματα, ψηφία, τελείες, παύλες ή κάτω παύλες.", + "Password must be at least 8 characters.": "Ο κωδικός πρέπει να έχει τουλάχιστον 8 χαρακτήρες.", + "Subdomain: lowercase letters, digits and single dashes; not a reserved name.": "Υποτομέας: πεζά λατινικά γράμματα, ψηφία και μονές παύλες· όχι δεσμευμένο όνομα.", + "Username or subdomain already taken.": "Το όνομα χρήστη ή ο υποτομέας χρησιμοποιείται ήδη.", + "A database %s already exists in Postgres (a leftover of a deleted blog?). Drop it or pick another subdomain.": "Υπάρχει ήδη βάση %s στην Postgres (υπόλειμμα διαγραμμένου ιστολογίου;). Διαγράψτε την ή διαλέξτε άλλον υποτομέα.", + "Created %s with blog %s.": "Δημιουργήθηκε ο χρήστης %s με ιστολόγιο %s.", + "You cannot do that to your own account here; use Account > Password.": "Δεν μπορείτε να το κάνετε αυτό στον δικό σας λογαριασμό από εδώ· χρησιμοποιήστε το Κωδικός.", + "Password reset for %s.": "Ο κωδικός του χρήστη %s άλλαξε.", + "%s enabled.": "Ο χρήστης %s ενεργοποιήθηκε.", + "%s disabled.": "Ο χρήστης %s απενεργοποιήθηκε.", + "This user owns the root blog and cannot be deleted.": "Ο χρήστης έχει το κεντρικό ιστολόγιο και δεν διαγράφεται.", + "Deleted %s and their blog.": "Διαγράφηκε ο χρήστης %s και το ιστολόγιό του.", + "Superadmin only.": "Μόνο για υπερδιαχειριστή.", + "This is not your blog.": "Αυτό δεν είναι το ιστολόγιό σας.", + "You have no blog yet. Ask the administrator to create one.": "Δεν έχετε ιστολόγιο ακόμα. Ζητήστε από τον διαχειριστή να το δημιουργήσει.", + "Could not read the form.": "Δεν ήταν δυνατή η ανάγνωση της φόρμας.", + "Form expired or invalid. Go back, reload the page and try again.": "Η φόρμα έληξε ή δεν είναι έγκυρη. Πηγαίνετε πίσω, ανανεώστε τη σελίδα και δοκιμάστε ξανά.", + "Upload too large: the limit is %d MB.": "Η μεταφόρτωση είναι πολύ μεγάλη: το όριο είναι %d MB.", + + // ---- public blog ---- + "Newer": "Νεότερες", + "Older": "Παλαιότερες", + "Page %d of %d": "Σελίδα %d από %d", + "Not found": "Δεν βρέθηκε", + "There is nothing at this address.": "Δεν υπάρχει τίποτα σε αυτή τη διεύθυνση.", + "Back to the front page.": "Πίσω στην αρχική σελίδα.", + "RSS feed": "Ροή RSS", +} diff --git a/internal/i18n/el_months.go b/internal/i18n/el_months.go new file mode 100644 index 0000000..acfd228 --- /dev/null +++ b/internal/i18n/el_months.go @@ -0,0 +1,7 @@ +package i18n + +var elMonths = months{ + name: [12]string{"Ιανουάριος", "Φεβρουάριος", "Μάρτιος", "Απρίλιος", "Μάιος", "Ιούνιος", "Ιούλιος", "Αύγουστος", "Σεπτέμβριος", "Οκτώβριος", "Νοέμβριος", "Δεκέμβριος"}, + inDate: [12]string{"Ιανουαρίου", "Φεβρουαρίου", "Μαρτίου", "Απριλίου", "Μαΐου", "Ιουνίου", "Ιουλίου", "Αυγούστου", "Σεπτεμβρίου", "Οκτωβρίου", "Νοεμβρίου", "Δεκεμβρίου"}, + short: [12]string{"Ιαν", "Φεβ", "Μαρ", "Απρ", "Μαΐ", "Ιουν", "Ιουλ", "Αυγ", "Σεπ", "Οκτ", "Νοε", "Δεκ"}, +} diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go new file mode 100644 index 0000000..4f31667 --- /dev/null +++ b/internal/i18n/i18n.go @@ -0,0 +1,110 @@ +// Package i18n holds the languages a blog can be in and the translations of +// the fixed text (dashboard, messages, dates). Keys are the English strings +// themselves, so an untranslated key renders as English rather than blank. +package i18n + +import ( + "fmt" + "strings" + "time" +) + +// Default is what new blogs get and what anything unknown falls back to. +const Default = "en" + +type Language struct { + Code, Name string // Name is in the language itself, for the settings menu +} + +var languages = []Language{ + {Code: "en", Name: "English"}, + {Code: "el", Name: "Ελληνικά"}, +} + +// catalogs maps a language to its translations; English is the identity. +var catalogs = map[string]map[string]string{ + "el": el, +} + +func Languages() []Language { return languages } + +func Valid(code string) bool { + for _, l := range languages { + if l.Code == code { + return true + } + } + return false +} + +// T translates key into lang, returning the key itself when there is no translation. +func T(lang, key string) string { + if s, ok := catalogs[lang][key]; ok { + return s + } + return key +} + +// Tf is T followed by Sprintf; translations may reorder arguments with %[n]s. +func Tf(lang, key string, args ...any) string { + return fmt.Sprintf(T(lang, key), args...) +} + +// Keys lists what a catalog translates (for the completeness test). +func Keys(lang string) []string { + out := make([]string, 0, len(catalogs[lang])) + for k := range catalogs[lang] { + out = append(out, k) + } + return out +} + +// Match picks a supported language from an Accept-Language header: the first +// tag (in header order) whose primary subtag we have, else Default. +func Match(acceptLanguage string) string { + for _, part := range strings.Split(acceptLanguage, ",") { + tag, _, _ := strings.Cut(strings.TrimSpace(part), ";") + primary, _, _ := strings.Cut(strings.ToLower(tag), "-") + if Valid(primary) { + return primary + } + } + return Default +} + +// months are the names a language needs: standalone (archive headings), in a +// date ("14 September" — Greek uses the genitive there) and abbreviated. +type months struct { + name, inDate, short [12]string +} + +var monthTables = map[string]months{ + "el": elMonths, +} + +// Month is the standalone month name (archive headings). +func Month(lang string, m time.Month) string { + if t, ok := monthTables[lang]; ok { + return t.name[m-1] + } + return m.String() +} + +// FormatDate renders a post date in the blogger's chosen style (long | short | iso). +func FormatDate(lang, style string, at time.Time) string { + if style == "iso" { + return at.Format("2006-01-02") + } + t, ok := monthTables[lang] + if !ok { // English: the Go layouts + if style == "short" { + return at.Format("2 Jan 2006") + } + return at.Format("2 January 2006") + } + m := t.inDate[at.Month()-1] + if style == "short" { + m = t.short[at.Month()-1] + } + return fmt.Sprintf("%d %s %d", at.Day(), m, at.Year()) +} diff --git a/internal/i18n/i18n_test.go b/internal/i18n/i18n_test.go new file mode 100644 index 0000000..e735b58 --- /dev/null +++ b/internal/i18n/i18n_test.go @@ -0,0 +1,63 @@ +package i18n + +import ( + "testing" + "time" +) + +func TestFormatDate(t *testing.T) { + at := time.Date(2026, 9, 3, 10, 0, 0, 0, time.UTC) + for _, c := range []struct{ lang, style, want string }{ + {"en", "long", "3 September 2026"}, {"en", "short", "3 Sep 2026"}, {"en", "iso", "2026-09-03"}, + {"el", "long", "3 Σεπτεμβρίου 2026"}, {"el", "short", "3 Σεπ 2026"}, {"el", "iso", "2026-09-03"}, + {"xx", "long", "3 September 2026"}, // unknown language reads as English + } { + if got := FormatDate(c.lang, c.style, at); got != c.want { + t.Errorf("%s/%s: got %q, want %q", c.lang, c.style, got, c.want) + } + } +} + +func TestMonth(t *testing.T) { + if got := Month("en", time.May); got != "May" { + t.Errorf("en: %q", got) + } + if got := Month("el", time.May); got != "Μάιος" { + t.Errorf("el: %q", got) + } + for _, tbl := range monthTables { + for i := 0; i < 12; i++ { + if tbl.name[i] == "" || tbl.inDate[i] == "" || tbl.short[i] == "" { + t.Errorf("month %d has an empty name", i+1) + } + } + } +} + +func TestTranslate(t *testing.T) { + if T("el", "Save") != "Αποθήκευση" || T("en", "Save") != "Save" || T("el", "no such key") != "no such key" { + t.Error("T") + } + if got := Tf("el", "Page %d of %d", 2, 5); got != "Σελίδα 2 από 5" { + t.Errorf("Tf: %q", got) + } + for _, l := range Languages() { + if !Valid(l.Code) { + t.Errorf("%s not valid", l.Code) + } + } + if Valid("") || Valid("fr") { + t.Error("Valid accepts unknown codes") + } +} + +func TestMatch(t *testing.T) { + for header, want := range map[string]string{ + "": "en", "fr": "en", "el": "el", "el-GR,el;q=0.9,en;q=0.8": "el", "en-US,en;q=0.9,el;q=0.8": "en", + "fr-FR, EL;q=0.5": "el", "*": "en", + } { + if got := Match(header); got != want { + t.Errorf("%q: got %q, want %q", header, got, want) + } + } +} |
