first commit

This commit is contained in:
User A0264400
2026-04-01 23:20:16 +03:00
commit a766acdc90
23071 changed files with 4933189 additions and 0 deletions

View File

@@ -0,0 +1,703 @@
<?php
/**
* A bundle may use one or more text domains, and may or may not physically house them.
* Essentially a bundle "uses" a text domain.
* Types are "theme", "plugin" and "core"
*/
abstract class Loco_package_Bundle extends ArrayObject implements JsonSerializable {
/**
* Internal handle for targeting in WordPress, e.g. "twentyfifteen" or "loco-translate/loco.php"
*/
private string $handle;
/**
* Short name, e.g. "twentyfifteen" or "loco-translate"
*/
private string $slug = '';
/**
* Friendly name, e.g. "Twenty Fifteen
*/
private string $name;
/**
* Full path to root directory of bundle
*/
private ?Loco_fs_Directory $root = null;
/**
* Directory paths to exclude from all projects
*/
private Loco_fs_FileList $xpaths;
/**
* Full path to PHP bootstrap file
*/
private ?string $boot = null;
/**
* Whether bundle is a single file, as opposed to in its own directory
*/
protected bool $solo = false;
/**
* Method with which bundle has been configured (file|db|meta|internal)
*/
private string $saved = '';
/**
* Get system (i.e. "global") target locations for all projects of this type.
* These are always append to configs, and always excluded from serialization
* @return string[] absolute directory paths
*/
abstract public function getSystemTargets():array;
/**
* Get canonical info registered with WordPress, i.e. plugin or theme headers
*/
abstract public function getHeaderInfo():Loco_package_Header;
/**
* Get built-in translatable values mapped to annotation for translators
*/
abstract public function getMetaTranslatable():array;
/**
* Get type of Bundle (title case)
*/
abstract public function getType():string;
/**
* Get absolute URL to bundle root, with trailing slash
*/
abstract public function getDirectoryUrl():string;
/**
* Construct bundle from unique ID containing type and handle
*/
public static function fromId( string $id ):self {
$r = explode( '.', $id, 2 );
return self::createType( $r[0], $r[1]??'' );
}
/**
* @throws Loco_error_Exception
*/
public static function createType( string $type, string $handle ):self {
$func = [ 'Loco_package_'.ucfirst($type), 'create' ];
if( is_callable($func) ){
$bundle = call_user_func( $func, $handle );
}
else {
throw new Loco_error_Exception('Unexpected bundle type: '.$type );
}
return $bundle;
}
/**
* Resolve a file path to a plugin, theme or the core
*/
public static function fromFile( Loco_fs_File $file ):?Loco_package_Bundle {
if( $file->underThemeDirectory() ){
return Loco_package_Theme::fromFile($file);
}
else if( $file->underPluginDirectory() ){
return Loco_package_Plugin::fromFile($file);
}
else if( $file->underWordPressDirectory() && ! $file->underContentDirectory() ){
return Loco_package_Core::create();
}
else {
return null;
}
}
/**
* Construct from WordPress handle and friendly name
* @param string $handle
* @param string $name
*/
public function __construct( $handle, $name ){
parent::__construct();
$this->setHandle($handle)->setName($name);
$this->xpaths = new Loco_fs_FileList;
}
/**
* Re-fetch this bundle from its currently saved location
*/
public function reload():self {
return call_user_func( [ get_class($this), 'create' ], $this->getSlug() );
}
/**
* Get ID that uniquely identifies bundle by its type and handle
*/
public function getId():string {
$type = strtolower( $this->getType() );
return $type.'.'.$this->getHandle();
}
/**
* @return string
*/
public function __toString(){
return $this->name;
}
/**
* Test if this bundle is a theme
*/
public function isTheme():bool {
return false;
}
/**
* Get parent bundle if possible. This can only be a theme.
* @codeCoverageIgnore
*/
public function getParent():?Loco_package_Theme {
trigger_error( $this->getType().' bundles cannot have parents. Check isTheme first');
return null;
}
/**
* Test if this bundle is a plugin
*/
public function isPlugin():bool {
return false;
}
/**
* Get handle of bundle unique for its type, e.g. "twentyfifteen" or "loco-translate/loco.php"
*/
public function getHandle():string {
return $this->handle;
}
/**
* Attempt to get the vendor-specific slug, which may or may not be the same as the internal handle
*/
public function getSlug():string {
// fall back to runtime handle if slug is empty
return $this->slug ?: $this->getHandle();
}
/**
* Set friendly name of bundle
*/
public function setName( string $name ):self{
$this->name = $name;
return $this;
}
/**
* Set short name of bundle which may or may not match unique handle
*/
public function setSlug( string $slug ):self {
$this->slug = $slug;
return $this;
}
/**
* Set internal handle registered with WordPress for this bundle type
*/
public function setHandle( string $handle ):self {
$this->handle = $handle;
return $this;
}
/**
* Get friendly name of bundle, e.g. "Twenty Fifteen" or "Loco Translate"
*/
public function getName():string {
return $this->name;
}
/**
* Whether bundle root is currently known
*/
public function hasDirectoryPath():bool {
return $this->root instanceof Loco_fs_Directory;
}
/**
* Set root directory for bundle. e.g. theme or plugin directory
*/
public function setDirectoryPath( string $path ):self {
$this->root = new Loco_fs_Directory( $path );
$this->root->normalize();
return $this;
}
/**
* Get absolute path to root directory for bundle. e.g. theme or plugin directory
*/
public function getDirectoryPath():string {
if( $this->root ){
return $this->root->getPath();
}
// without a root directory return WordPress root
return untrailingslashit(ABSPATH);
}
/**
* @return string[]
*/
public function getVendorRoots():array {
$dirs = [];
$base = $this->getDirectoryPath();
foreach( ['node_modules','vendor'] as $f ){
$path = $base.'/'.$f;
if( Loco_fs_File::is_readable($path) && is_dir($path) ){
$dirs[] = $path;
}
}
return $dirs;
}
/**
* Get file locations to exclude from all projects in bundle. These are effectively "hidden"
*/
public function getExcludedLocations():Loco_fs_FileList {
return $this->xpaths;
}
/**
* Add a path for excluding from all projects
* @param Loco_fs_File|string $path
*/
public function excludeLocation( $path ):self {
$this->xpaths->add( new Loco_fs_File($path) );
return $this;
}
/**
* Create a file searcher from root location, excluding that which is excluded
*/
public function getFileFinder():Loco_fs_FileFinder {
$root = $this->getDirectoryPath();
/*/ if bundle is symlinked it's resource files won't be matched properly
if( is_link($root) && ( $real = realpath($root) ) ){
$root = $real;
}*/
$finder = new Loco_fs_FileFinder( $root );
foreach( $this->xpaths as $path ){
$finder->exclude( (string) $path );
}
return $finder;
}
/**
* Get primary PHP source file containing bundle bootstrap code, if applicable
*/
public function getBootstrapPath():?string {
return $this->boot;
}
/**
* Set primary PHP source file containing bundle bootstrap code, if applicable.
* @param string|Loco_fs_File $path to PHP file
* @return Loco_package_Bundle
*/
public function setBootstrapPath( $path ):self {
$path = (string) $path;
// sanity check this is a PHP file even if it doesn't exist
if( '.php' !== substr($path,-4) ){
throw new Loco_error_Exception('Bootstrap file should end .php, got '.$path );
}
$this->boot = $path;
// base directory can be inferred from bootstrap path
if( ! $this->hasDirectoryPath() ){
$this->setDirectoryPath( dirname($path) );
}
return $this;
}
/**
* Test whether bundle consists of a single file
*/
public function isSingleFile():bool {
return $this->solo;
}
/**
* Add all projects defined in a TextDomain
*/
public function addDomain( Loco_package_TextDomain $domain ):self {
/* @var Loco_package_Project $proj */
foreach( $domain as $proj ){
$this->addProject($proj);
}
return $this;
}
/**
* Add a translation project to bundle.
* Note that this always adds without checking uniqueness. Call hasProject first if it could be a duplicate
*/
public function addProject( Loco_package_Project $project ):self {
// add global targets
foreach( $this->getSystemTargets() as $path ){
$project->addSystemTargetDirectory( $path );
}
// add global exclusions affecting source and target locations
foreach( $this->xpaths as $path ){
$project->excludeLocation( $path );
}
// projects must be unique by Text Domain and "slug" (used to prefix files)
// however, I am not indexing them here on purpose so domain and slug may be added at any time.
$this[] = $project;
return $this;
}
/**
* Export projects grouped by domain
* @return array[] indexed by Text Domain name
*/
public function exportGrouped():array {
$domains = [];
/* @var $proj Loco_package_Project */
foreach( $this as $proj ){
$domain = $proj->getDomain();
$key = $domain->getName();
$domains[$key][] = $proj;
}
return $domains;
}
/**
* Create a suitable Text Domain from bundle's name.
* Note that internal handle may be a directory name differing entirely from the author's intention, hence the configured bundle name is slugged instead
*/
public function createDomain():Loco_package_TextDomain {
$slug = sanitize_title( $this->name, $this->slug );
return new Loco_package_TextDomain( $slug );
}
/**
* Generate default configuration.
* Adds a simple one domain, one project config
* @param string|null $domainName optional Text Domain to use
*/
public function createDefault( ?string $domainName = null ):Loco_package_Project {
if( is_null($domainName) ){
$domain = $this->createDomain();
}
else {
$domain = new Loco_package_TextDomain($domainName);
}
$project = $domain->createProject( $this, $this->name );
if( $this->solo ){
$project->addSourceFile( $this->getBootstrapPath() );
}
else {
$project->addSourceDirectory( $this->getDirectoryPath() );
}
$this->addProject( $project );
return $project;
}
/**
* Configure from custom saved option
* @return bool whether configured via database option
*/
public function configureDb():bool {
$option = $this->getCustomConfig();
if( $option instanceof Loco_config_CustomSaved ){
$option->configure();
$this->saved = 'db';
return true;
}
return false;
}
/**
* Configure from XML config
* @return bool whether configured via static XML file
*/
public function configureXml():bool {
$xmlfile = $this->getConfigFile();
if( $xmlfile instanceof Loco_fs_File ){
$reader = new Loco_config_BundleReader($this);
$reader->loadXml( $xmlfile );
$this->saved = 'file';
return true;
}
return false;
}
/**
* Get XML configuration file used to define this bundle
*/
public function getConfigFile():?Loco_fs_File {
$base = $this->getDirectoryPath();
$file = new Loco_fs_File( $base.'/loco.xml' );
if( ! $file->exists() || ! loco_check_extension('dom') ){
return null;
}
return $file;
}
/**
* Check whether bundle is manually configured, as opposed to guessed (file|db|meta|internal)
*/
public function isConfigured():string {
return $this->saved;
}
/**
* Do basic configuration from bundle meta data (file headers)
* @param array $header tags from theme or plugin bootstrap file
* @return bool whether configured via header tags
*/
public function configureMeta( array $header ):bool {
if( isset($header['Name']) ){
$this->setName( $header['Name'] );
}
if( isset($header['TextDomain']) && ( $slug = $header['TextDomain'] ) ){
$domain = new Loco_package_TextDomain($slug);
$domain->setCanonical( true );
// use domain as bundle handle and slug if not set when constructed
if( ! $this->handle ){
$this->handle = $slug;
}
if( ! $this->getSlug() ){
$this->setSlug( $slug );
}
$project = $domain->createProject( $this, $this->name );
// May have declared DomainPath
$base = $this->getDirectoryPath();
if( isset($header['DomainPath']) && ( $path = trim($header['DomainPath'],'/') ) ){
$project->addTargetDirectory( $base.'/'.$path );
}
// else use standard language path if it exists
else if( ! $this->solo ){
if( is_dir($base.'/languages') ) {
$project->addTargetDirectory($base.'/languages');
}
// else add bundle root by default
else {
$project->addTargetDirectory($base);
}
}
// single file bundles can have only one source file
if( $this->solo ){
$project->addSourceFile( $this->getBootstrapPath() );
}
// else add bundle root as default source file location
else {
$project->addSourceDirectory( $base );
}
// automatically block common vendor locations
foreach( $this->getVendorRoots() as $root ){
$this->excludeLocation($root);
}
// default domain added
$this->addProject($project);
$this->saved = 'meta';
return true;
}
return false;
}
/**
* Configure bundle from canonical sources.
* Source order is "db","file","meta" where meta is the auto-config fallback.
* No deep scanning is performed at this point
* @param string[] $header tags from theme or plugin bootstrap file
*/
public function configure( string $base, array $header ):self {
$this->setDirectoryPath( $base );
$this->configureDb() || $this->configureXml() || $this->configureMeta($header);
do_action('loco_bundle_configured',$this);
return $this;
}
/**
* Get the custom config saved in WordPress DB for this bundle
*/
public function getCustomConfig():?Loco_config_CustomSaved {
$custom = new Loco_config_CustomSaved;
if( $custom->setBundle($this)->fetch() ){
return $custom;
}
return null;
}
/**
* Inherit another bundle. Used for child themes to display parent translations
*/
public function inherit( Loco_package_Bundle $parent ):self{
foreach( $parent as $project ){
if( ! $this->hasProject($project) ){
$this->addProject( $project );
}
}
return $this;
}
/**
* Get unique translation project by text domain (and optionally slug)
* TODO would prefer to avoid iteration, but slug can be changed at any time
*/
public function getProject( string $domain, ?string $slug = null ):?Loco_package_Project {
if( is_null($slug) ){
$slug = $domain;
}
/* @var $project Loco_package_Project */
foreach( $this as $project ){
if( $project->getSlug() === $slug && $project->getDomain()->getName() === $domain ){
return $project;
}
}
return null;
}
/**
* Get the primary translation set for this bundle, unless there are zero
*/
public function getDefaultProject():?Loco_package_Project {
$i = 0;
/* @var Loco_package_Project $project */
foreach( $this as $project ){
if( $project->isDomainDefault() ){
return $project;
}
$i++;
}
// nothing is domain default, but if we only have one, then duh
if( 1 === $i ){
return $project;
}
return null;
}
/**
* Test if project already exists in bundle
*/
public function hasProject( Loco_package_Project $project ):bool {
return (bool) $this->getProject( $project->getDomain()->getName(), $project->getSlug() );
}
/**
* @return Loco_package_TextDomain[]
*/
public function getDomains():array {
$domains = [];
/* @var $project Loco_package_Project */
foreach( $this as $project ){
if( $domain = $project->getDomain() ){
$d = (string) $domain;
if( ! isset($domains[$d]) ){
$domains[$d] = $domain;
}
}
}
return $domains;
}
/**
* Get newest timestamp of all translation files (includes template, but exclude source files)
*/
public function getLastUpdated():int {
// recent items is a convenient cache for checking last modified times
$t = Loco_data_RecentItems::get()->hasBundle( $this->getId() );
// else have to scan targets across all projects
if( 0 === $t ){
/* @var Loco_package_Project $project */
foreach( $this as $project ){
$t = max( $t, $project->getLastUpdated() );
}
}
return $t;
}
/**
* Get project by ID
* @param string $id identifier of the form <domain>[.<slug>]
*/
public function getProjectById( string $id ):?Loco_package_Project {
list( $domain, $slug ) = Loco_package_Project::splitId($id);
return $this->getProject( $domain, $slug );
}
/**
* Reset bundle configuration, but keep metadata like name and slug.
* Call this before applying a saved config, otherwise values will just be added on top.
*/
public function clear():self {
$this->exchangeArray( [] );
$this->xpaths = new Loco_fs_FileList;
$this->saved = '';
return $this;
}
#[ReturnTypeWillChange]
public function jsonSerialize():array {
$writer = new Loco_config_BundleWriter( $this );
return $writer->toArray();
}
/**
* Create a copy of this bundle containing any files found that aren't currently configured
*/
public function invert():self {
return Loco_package_Inverter::compile( $this );
}
}

View File

@@ -0,0 +1,151 @@
<?php
/**
* The "WordPress Core" translations bundle
*/
class Loco_package_Core extends Loco_package_Bundle {
/**
* {@inheritdoc}
*/
public function getSystemTargets(): array {
return [
untrailingslashit( loco_constant('LOCO_LANG_DIR') ),
untrailingslashit( loco_constant('WP_LANG_DIR') )
];
}
/**
* {@inheritdoc}
*/
public function getHeaderInfo(): Loco_package_Header {
return new Loco_package_Header( [
'TextDomain' => 'default',
'DomainPath' => '/wp-content/languages/',
// dummy author info for core components
'Name' => __('WordPress core','loco-translate'),
'Version' => $GLOBALS['wp_version'],
'Author' => __('The WordPress Team'),
'AuthorURI' => __('https://wordpress.org/'),
] );
}
/**
* {@inheritdoc}
*/
public function getMetaTranslatable(): array {
return [];
}
/**
* {@inheritdoc}
*/
public function getType(): string {
return 'Core';
}
/**
* {@inheritdoc}
* Core bundle doesn't need a handle, there is only one.
*/
public function getId(): string {
return 'core';
}
/**
* {@inheritDoc}
*/
public function getDirectoryUrl(): string {
return get_site_url(null,'/');
}
/**
* {@inheritdoc}
* Core bundle is always configured
*/
public function isConfigured(): string {
return parent::isConfigured() ?: 'internal';
}
/**
* Manually define the core WordPress translations as a single bundle
* Projects are those included in standard WordPress downloads: [default], "admin", "admin-network" and "continents-cities"
*/
public static function create():self {
$rootDir = loco_constant('ABSPATH');
$langDir = loco_constant('WP_LANG_DIR');
$bundle = new Loco_package_Core('core', __('WordPress Core','loco-translate') );
$bundle->setDirectoryPath( $rootDir );
// Core config may be saved in DB, but not supporting bundled XML
if( $bundle->configureDb() ){
return $bundle;
}
// front end, admin and network admin packages are all part of the "default" domain
$domain = new Loco_package_TextDomain('default');
$domain->setCanonical( true );
// front end subset, has empty name in WP
// full title is like "4.9.x - Development" but we don't know what version at this point
list($x,$y) = explode('.',$GLOBALS['wp_version'],3);
$project = $domain->createProject( $bundle, sprintf('%u.%u.x - Development',$x,$y) );
$project->setSlug('')
->setPot( new Loco_fs_File($langDir.'/wordpress.pot') )
->addSourceDirectory( $rootDir)
->excludeSourcePath( $rootDir.'/wp-admin')
->excludeSourcePath( $rootDir.'/wp-content')
->excludeSourcePath( $rootDir.'/wp-includes/class-pop3.php')
->excludeSourcePath( $rootDir.'/wp-includes/js/codemirror')
->excludeSourcePath( $rootDir.'/wp-includes/js/crop')
->excludeSourcePath( $rootDir.'/wp-includes/js/imgareaselect')
->excludeSourcePath( $rootDir.'/wp-includes/js/jcrop')
->excludeSourcePath( $rootDir.'/wp-includes/js/jquery')
->excludeSourcePath( $rootDir.'/wp-includes/js/mediaelement')
->excludeSourcePath( $rootDir.'/wp-includes/js/plupload')
->excludeSourcePath( $rootDir.'/wp-includes/js/swfupload')
->excludeSourcePath( $rootDir.'/wp-includes/js/thickbox')
->excludeSourcePath( $rootDir.'/wp-includes/js/tw-sack.js')
;
// "Administration" project (admin subset)
$project = $domain->createProject( $bundle, 'Administration');
$project->setSlug('admin')
->setPot( new Loco_fs_File($langDir.'/admin.pot') )
->addSourceDirectory( $rootDir.'/wp-admin' )
->excludeSourcePath( $rootDir.'/wp-admin/js')
->excludeSourcePath( $rootDir.'/wp-admin/css')
->excludeSourcePath( $rootDir.'/wp-admin/network')
->excludeSourcePath( $rootDir.'/wp-admin/network.php')
->excludeSourcePath( $rootDir.'/wp-admin/includes/continents-cities.php')
;
// "Network Admin" package (admin-network subset)
$project = $domain->createProject($bundle, 'Network Admin');
$project->setSlug('admin-network')
->setPot( new Loco_fs_File($langDir.'/admin-network.pot') )
->addSourceDirectory( $rootDir.'/wp-admin/network' )
->addSourceFile( $rootDir.'/wp-admin/network.php' )
;
// end of "default" domain projects
$bundle->addDomain( $domain );
// Continents & Cities is its own text domain
$domain = new Loco_package_TextDomain('continents-cities');
$project = $domain->createProject( $bundle, 'Continents & Cities');
$project->setPot( new Loco_fs_File( $langDir.'/continents-cities.pot') )
->addSourceFile( $rootDir.'/wp-admin/includes/continents-cities.php' )
;
$bundle->addDomain( $domain );
return $bundle;
}
}

View File

@@ -0,0 +1,115 @@
<?php
/**
* Common access to bundle headers.
* Because access to theme and plugin header data via WordPress is a total mess.
*
* @property-read string $Name
* @property-read string $Version
* @property-read string $Author
* @property-read string $AuthorURI
* @property-read string $PluginURI
* @property-read string $ThemeURI
* @property-read string $TextDomain
* @property-read string $DomainPath
*/
class Loco_package_Header {
/**
* WordPress's internal data
* @var array|ArrayAccess
*/
private $wp;
public function __construct( $header ){
$this->wp = $header;
}
/**
* @param string $prop
* @return string
*/
public function __get( $prop ){
$wp = $this->wp;
// prefer "get" method to access raw properties (WP_Theme)
if( is_object($wp) && method_exists($wp,'get') ){
$value = $wp->get($prop);
if( is_string($value) && '' !== $value ){
return $value;
}
}
// may have key directly, e.g. TextDomain in plugin array
if( isset($wp[$prop]) ){
return $wp[$prop];
}
// else header not defined, which is probably fine
return '';
}
/**
* @param string $prop
* @param mixed $value
* @codeCoverageIgnore
*/
public function __set( $prop, $value ){
throw new LogicException('Read only');
}
/**
* Get bundle author as linked text, just like the WordPress plugin list does
* @return string escaped HTML
*/
public function getAuthorLink():string {
if( ( $link = $this->AuthorURI ) || ( $link = $this->PluginURI ) || ( $link = $this->ThemeURI ) ){
$author = $this->Author or $author = $link;
return '<a href="'.esc_url($link).'" target="_blank">'.esc_html($author).'</a>';
}
return '';
}
/**
* Get "name" by <author> credit
* @return string escaped HTML
*/
public function getAuthorCredit(): string {
if( $author = $this->Author ){
$author = esc_html( strip_tags($author) );
if( $link = $this->AuthorURI ){
$author = '<a href="'.esc_url($link).'" target="_blank">'.$author.'</a>';
}
}
else {
$author = __('Unknown author','loco-translate');
}
// translators: Author credit: (1) Product name (2) version number, (3) author name.
$html = wp_kses( sprintf( __('"%1$s" %2$s by %3$s','loco-translate'), $this->Name, $this->Version, $author ), ['a'=>['href'=>true,'target'=>true]], ['http','https'] );
$link = $this->PluginURI ?: $this->ThemeURI;
if( $link ){
$html .= sprintf( ' &mdash; <a href="%s" target="_blank">%s</a>', esc_url($link), esc_html(__('Visit official site','loco-translate')) );
}
return $html;
}
/**
* Get hostname of vendor that hosts theme/plugin
* @return string e.g. "wordpress.org"
*/
public function getVendorHost(): string {
$host = '';
if( ( $url = $this->PluginURI ) || ( $url = $this->ThemeURI ) ){
if( $host = parse_url($url,PHP_URL_HOST) ){
$bits = explode( '.', $host );
$host = implode( '.', array_slice($bits,-2) );
}
}
return $host;
}
}

View File

@@ -0,0 +1,170 @@
<?php
/**
* Bundle inverter utility class.
*/
abstract class Loco_package_Inverter {
/**
* Get all Gettext files that are not configured and valid in the given bundle
* @return array
*/
public static function export( Loco_package_Bundle $bundle ){
// search paths for inverted bundle will exclude global ignore paths,
// plus anything known to the current configuration which we'll add now.
$finder = $bundle->getFileFinder();
/* @var $project Loco_package_Project */
foreach( $bundle as $project ){
if( $file = $project->getPot() ){
// excluding all extensions in case POT is actually a PO/MO pair
foreach( ['pot','po','mo'] as $ext ){
$file = $file->cloneExtension($ext);
if( $path = realpath( $file->getPath() ) ){
$finder->exclude( $path );
}
}
}
foreach( $project->findLocaleFiles('po') as $file ){
if( $path = realpath( $file->getPath() ) ){
$finder->exclude( $path );
}
}
foreach( $project->findLocaleFiles('mo') as $file ){
if( $path = realpath( $file->getPath() ) ){
$finder->exclude( $path );
}
}
}
// Do a deep scan of all files that haven't been seen, or been excluded:
// This will include files in global directories and inside the bundle.
return $finder->setRecursive(true)->followLinks(false)->group('po','mo','pot')->exportGroups();
}
/**
* Compile anything found under bundle root that isn't configured in $known
* @return Loco_package_Bundle
*/
public static function compile( Loco_package_Bundle $bundle ){
$found = self::export($bundle);
// done with original bundle now
$bundle = clone $bundle;
$bundle->clear();
// first iteration groups found files into common locations that should hopefully indicate translation sets
$groups = [];
$templates = [];
$localised = [];
$root = $bundle->getDirectoryPath();
/* @var $list Loco_fs_FileList */
foreach( $found as $ext => $list ){
/* @var $file Loco_fs_LocaleFile */
foreach( $list as $file ){
// printf("Found: %s <br />\n", $file );
// This file is NOT known to be part of a configured project
$dir = $file->getParent();
$key = $dir->getRelativePath( $root );
//
if( ! isset($groups[$key]) ){
$groups[$key] = $dir;
$templates[$key] = [];
$localised[$key] = [];
}
// template should define single set of translations unique by directory and file prefix
if( 'pot' === $ext ){
$slug = $file->filename();
$templates[$key][$slug] = true;
}
// else ideally PO/MO files will correspond to a template by common prefix
else {
$file = new Loco_fs_LocaleFile( $file );
$slug = $file->getPrefix();
if( $file->getLocale()->isValid() ){
$localised[$key][$slug] = true;
}
// else could be some kind of non-standard template
else {
$slug = $file->filename();
$templates[$key][$slug] = true;
}
}
}
}
unset($found);
// next iteration matches collected files together into likely project sets
$unique = [];
/* @var $list Loco_fs_Directory */
foreach( $groups as $key => $dir ){
// pair up all projects that match templates neatly to prefixed files
foreach( $templates[$key] as $slug => $bool ){
if( isset($localised[$key][$slug]) ){
//printf("Perfect match on domain '%s' in %s <br />\n", $slug, $key );
$unique[$key][$slug] = $dir;
// done with this perfectly matched set
$templates[$key][$slug] = null;
$localised[$key][$slug] = null;
}
}
// pair up any unprefixed localised files
if( isset($localised[$key]['']) ){
$slug = 'unknown';
// Match to first (hopefully only) template to establish a slug
foreach( $templates[$key] as $_slug => $bool ){
if( $bool ){
$slug = $_slug;
$templates[$key][$slug] = null;
break; // <- not possible to know how multiple POTs might be paired up
}
}
//printf("Pairing unprefixed files in %s to '%s' <br />\n", $key, $slug );
$unique[$key][$slug] = $dir;
// done with unprefixed localised files in this directory
$localised[$key][''] = null;
}
// add any orphaned translations (those with no template matched)
foreach( $localised[$key] as $slug => $bool ){
if( $bool ){
// printf("Picked up orphoned locales in %s as '%s' <br />\n", $key, $slug );
$unique[$key][$slug] = $dir;
}
}
// add any orphaned templates (those with no localised files matched)
foreach( $templates[$key] as $slug => $bool ){
if( $bool ){
//printf("Picked up orphoned template in %s as '%s' <br />\n", $key, $slug );
$unique[$key][$slug] = $dir;
}
}
}
unset( $groups, $localised, $templates );
// final iteration adds unique projects to bundle
foreach( $unique as $key => $sets ){
foreach( $sets as $slug => $dir ){
$name = ucfirst( strtr( $slug, '-_', ' ' ) );
$domain = new Loco_package_TextDomain( $slug );
$project = $domain->createProject( $bundle, $name );
$project->addTargetDirectory($dir);
$bundle->addProject($project);
}
// TODO how to prevent overlapping sets by adding each other's files to exclude lists
}
return $bundle;
}
}

View File

@@ -0,0 +1,322 @@
<?php
/**
* Captures text domains being loaded at runtime and establishes what bundles they belong to.
* Note that since WordPress 6.7 all text domains are loaded JIT, so we won't see them unless they're used.
*/
class Loco_package_Listener extends Loco_hooks_Hookable {
/**
* Global availability of a single listener
* @var self
*/
private static $singleton;
/**
* Buffer of captured text domain loads before they're resolved
* @var array[]
*/
private $buffer;
/**
* Resolved plugin bundles, indexed by slug (relative file path)
* @var Loco_package_Plugin[]
*/
private $plugins;
/**
* Map of all established bundle's and their *primary* text domain
* @var array { slug: domain }
*/
private $domains;
/**
* Map of all text domains and their official directory location
* @var array { domain: path }
*/
private $domainPaths;
/**
* Map of all known plugin handles indexed by their relative containing directory
* @var array { dirname: handle }
*/
private $pluginHandles;
/**
* Get singleton listener or create new if not already exists
* @return Loco_package_Listener
*/
public static function singleton(){
$active = self::$singleton or $active = self::create();
return $active;
}
/**
* @internal
*/
public static function destroy(){
if( $active = self::$singleton ){
$active->unhook();
self::$singleton = null;
}
}
/**
* Create a singleton listener that we can query from anywhere
* @return Loco_package_Listener
*/
public static function create(){
self::destroy();
self::$singleton = new Loco_package_Listener;
return self::$singleton->clear();
}
/**
* @return Loco_package_Listener
*/
public function clear(){
$this->buffer = [];
$this->plugins = [];
$this->domains = [];
$this->domainPaths = [];
$this->pluginHandles = null;
return $this;
}
/**
* Early hook listening for active bundles loading their own text domains.
* @noinspection PhpUnused
*/
public function on_load_textdomain( $domain, $mofile ){
if( '' === $domain || 'default' === $domain ){
return;
}
$this->buffer[$domain][] = $mofile;
}
/**
* Get primary Text Domain that's uniquely assigned to a bundle.
* @param string $handle theme or plugin relative path
*/
public function getDomain( string $handle ){
$this->flush();
return $this->domains[$handle]??'';
}
/**
* Get the default directory path where captured files of a given domain are held
* @param string $domain TextDomain
* @return string relative path
*/
public function getDomainPath( string $domain ):string {
$this->flush();
return $this->domainPaths[$domain]??'';
}
/**
* Trim containing directory from a path, so "languages/foo/bar" -> "foo/bar", or "languages" -> ""
*/
private static function subdir( string $path ):string {
$bits = explode('/',$path,2);
return $bits[1] ?? '';
}
/**
* Trim subdirectories from a path so "languages/foo/bar" -> "languages"
*/
private static function topdir( $path ){
return explode('/',$path,2)[0];
}
/**
* Resolve any path under a plugin directory to a plugin bundle.
* @param string $path relative plugin path, e.g. "loco-translate/languages/foo.po"
* @return void
*/
private function resolvePluginFromPath( string $path, $domain, $slug ){
// cache all root directory names
if( ! $this->pluginHandles ){
$this->pluginHandles = [];
foreach( Loco_package_Plugin::get_plugins() as $handle => $data ){
$this->pluginHandles[ dirname($handle) ] = $handle;
// set default text domain because additional domains could be discovered before the canonical one
if( isset($data['TextDomain']) && '' !== $data['TextDomain'] ){
$this->domains[$handle] = $data['TextDomain'];
}
}
}
// check root directory name exists in indexed plugin roots
$name = self::topdir($path);
if( array_key_exists($name, $this->pluginHandles) ) {
$handle = $this->pluginHandles[ $name ];
}
else {
return;
}
// set this as default domain if not already cached
if( ! isset($this->domains[$handle]) ){
$this->domains[$handle] = $domain;
}
if( $slug !== $domain ){
$this->domains[$slug] = $domain;
}
// plugin bundle may already exist
if( isset($this->plugins[$handle]) ){
$bundle = $this->plugins[$handle];
}
// create default project for plugin bundle (not necessarily the current text domain)
else {
$bundle = Loco_package_Plugin::create($handle);
$this->plugins[$handle] = $bundle;
}
// add current domain as translation project if not already set
// this avoids extra domains getting set before the default one
if( ! $bundle->getProject($slug) ){
$project = new Loco_package_Project( $bundle, new Loco_package_TextDomain($domain), $slug );
$bundle->addProject( $project );
}
}
/**
* @return void
*/
private function resolvePluginFromSlug( $slug, $domain ){
// if we're lucky the plugin's directory will match. But we don't know the file name
foreach( Loco_fs_Locations::getPlugins()->apply($slug) as $path ){
if( is_dir($path) ){
$this->resolvePluginFromPath( basename($path).'/dummy.ext', $domain, $slug );
break;
}
}
}
/**
* @return bool
*/
private function resolveThemeFromPath( $path, $root, $domain ){
$handle = self::topdir($path);
$theme = new WP_Theme( $handle, $root );
if( ! $theme->exists() ){
return false;
}
// theme may have officially declared text domain
if( $default = $theme->get('TextDomain') ){
$this->domains[$handle] = $default;
}
// else set current domain as default if not already set
else if ( ! isset($this->domains[$handle]) ){
$this->domains[$handle] = $domain;
}
if( ! isset($this->domainPaths[$domain]) ){
$this->domainPaths[$domain] = self::subdir($path);
}
// theme configuration may use domains and domain paths set above
// return Loco_package_Theme::createFromTheme($theme);
return true;
}
/**
* @return void
*/
private function resolveThemeFromSlug( $slug, $domain ){
// if we're lucky the theme's directory will match the domain. But we don't know the file name
foreach( Loco_fs_Locations::getThemes()->apply($slug) as $path ){
if( is_dir($path) ){
$this->resolveThemeFromPath( $slug, dirname($path), $domain );
break;
}
}
}
/**
* @return void
*/
private function resolve( $path, $domain ){
$file = new Loco_fs_LocaleFile( $path );
// ignore suffix-only files when locale is invalid as locale code would be taken wrongly as slug, e.g. if you tried to load "english.po"
if( $file->hasPrefixOnly() ){
return;
}
$dir = dirname($path);
// theme author: (no file prefix)
$split = Loco_fs_Locations::getThemes()->split($dir);
if( $split ){
[$root,$rel] = $split;
if( $this->resolveThemeFromPath($rel,$root,$domain) ){
return;
}
// try plugins, as they may be held in themes
}
// file prefix is probably the Text Domain, but we can't guarantee it
$slug = $file->getPrefix()?:$domain;
// plugin author:
if( $rel = Loco_fs_Locations::getPlugins()->rel($path) ){
$this->domainPaths[$domain] = self::subdir( dirname($rel) );
$this->resolvePluginFromPath($rel,$domain,$slug);
}
// Language domain locations
else if( Loco_fs_Locations::getLangs()->check($path) ){
// immediate parent will be either plugins or themes, else the root (default domain)
$type = basename( dirname($path) );
if( 'plugins' === $type ){
$this->resolvePluginFromSlug($slug,$domain);
}
else if( 'themes' === $type ){
$this->resolveThemeFromSlug($slug,$domain);
}
}
}
/**
* @internal
* Resolve all currently buffered text domain paths
*/
private function flush(){
if( $this->buffer ){
$buffer = $this->buffer;
$this->buffer = [];
foreach( $buffer as $domain => $paths ){
foreach( $paths as $path ){
try {
if( $this->resolve($path,$domain) ){
continue 2;
}
}
catch( Loco_error_Exception $e ){
// silent errors for non-critical function
}
}
}
}
}
/**
* @return array
*/
public function getPlugins(){
$this->flush();
return $this->plugins;
}
}

View File

@@ -0,0 +1,122 @@
<?php
/**
* Provides iteration over all installed files for a given language and matches them to bundles
*/
class Loco_package_Locale {
/**
* @var array
*/
private $match;
/**
* @var array
*/
private $bundles;
/**
* Maps file paths to projects in which they were found
* @var ArrayObject
*/
private $index;
/**
* Construct with locale to filter on
*/
public function __construct( ?Loco_locale $locale = null ){
$this->index = new ArrayObject;
$this->match = [];
if( $locale ){
$this->addLocale( $locale );
}
}
/**
* Add another locale to search on
*/
public function addLocale( Loco_Locale $locale ):self {
if( $locale->isValid() ){
$sufx = $locale.'.po';
$this->match[$sufx] = - strlen($sufx);
}
return $this;
}
/**
* @return Loco_package_Project|null
*/
public function getProject( Loco_fs_File $file ){
$path = $file->getPath();
if( isset($this->index[$path]) ){
return $this->index[$path];
}
return null;
}
/**
* @return Loco_package_Bundle[]
*/
public function getBundles(){
$bundles = $this->bundles;
if( ! $bundles ){
$bundles = [ Loco_package_Core::create() ];
$bundles = array_merge( $bundles, Loco_package_Plugin::getAll() );
$bundles = array_merge( $bundles, Loco_package_Theme::getAll() );
$this->bundles = $bundles;
}
return $bundles;
}
/**
* @return loco_fs_FileList
*/
public function findLocaleFiles(){
$index = $this->index;
$suffixes = $this->match;
$list = new Loco_fs_FileList;
foreach( $this->getBundles() as $bundle ){
/* @var Loco_package_Project $project */
foreach( $bundle as $project ){
/* @var $file Loco_fs_File */
foreach( $project->findLocaleFiles('po') as $file ){
$path = $file->getPath();
foreach( $suffixes as $sufx => $snip ){
if( substr($path,$snip) === $sufx ){
$list->add( $file );
$index[$path] = $project;
break;
}
}
}
}
}
return $list;
}
/**
* @return loco_fs_FileList
*/
public function findTemplateFiles(){
$index = $this->index;
$list = new Loco_fs_FileList;
foreach( $this->getBundles() as $bundle ){
/* @var $project Loco_package_Project */
foreach( $bundle as $project ){
$file = $project->getPot();
if( $file && $file->exists() ){
$list->add( $file );
$path = $file->getPath();
$index[$path] = $project;
}
}
}
return $list;
}
}

View File

@@ -0,0 +1,286 @@
<?php
/**
* Represents a bundle of type "plugin"
*/
class Loco_package_Plugin extends Loco_package_Bundle {
/**
* {@inheritdoc}
*/
public function getSystemTargets(): array {
return [
trailingslashit( loco_constant('LOCO_LANG_DIR') ).'plugins',
trailingslashit( loco_constant('WP_LANG_DIR') ).'plugins',
];
}
/**
* {@inheritdoc}
*/
public function isPlugin(): bool {
return true;
}
/**
* {@inheritdoc}
*/
public function getType(): string {
return 'Plugin';
}
/**
* {@inheritDoc}
*/
public function getDirectoryUrl(): string {
return plugins_url('/',$this->getHandle());
}
/**
* {@inheritdoc}
*/
public function getSlug(): string {
// Fallback to first handle component
return explode( '/', parent::getSlug(), 2 )[0];
}
/**
* @return Loco_package_Plugin[]
*/
public static function getAll(): array {
$plugins = [];
foreach( self::get_plugins() as $handle => $data ){
try {
$plugins[] = Loco_package_Plugin::create($handle);
}
catch( Exception $e ){
// @codeCoverageIgnore
}
}
return $plugins;
}
/**
* Maintaining our own cache of full paths to available plugins, because get_mu_plugins doesn't get cached by WP
* @return array[]
*/
public static function get_plugins(): array {
$cached = wp_cache_get('plugins','loco');
if( ! is_array($cached) ){
$cached = [];
// regular plugins + mu plugins:
$search = [
'WP_PLUGIN_DIR' => 'get_plugins',
'WPMU_PLUGIN_DIR' => 'get_mu_plugins',
];
foreach( $search as $const => $getter ){
if( $list = call_user_func($getter) ){
$base = loco_constant($const);
foreach( $list as $handle => $data ){
if( isset($cached[$handle]) ){
Loco_error_AdminNotices::debug( sprintf('Plugin conflict on %s', $handle) );
continue;
}
// WordPress 4.6 introduced TextDomain header fallback @37562 see https://core.trac.wordpress.org/changeset/37562/
// if we don't force the original text domain header we can't know if a bundle is misconfigured. This leads to silent errors.
// this has a performance overhead, and also results in "unconfigured" messages that users may not have had in previous releases.
/*/ TODO perhaps implement a plugin setting that forces original headers
$file = new Loco_fs_File($base.'/'.$handle);
if( $file->exists() ){
$map = array( 'TextDomain' => 'Text Domain' );
$raw = get_file_data( $file->getPath(), $map, 'plugin' );
$data['TextDomain'] = $raw['TextDomain'];
}*/
// set resolved base directory before caching our copy of plugin data
$data['basedir'] = $base;
$cached[$handle] = $data;
}
}
}
$cached = apply_filters('loco_plugins_data', $cached );
uasort( $cached, '_sort_uname_callback' );
// Intended as in-memory cache so adding short expiry for object caching plugins that may persist it.
// All actions that invoke `wp_clean_plugins_cache` should purge this. See Loco_hooks_AdminHooks
wp_cache_set('plugins', $cached, 'loco', 3600 );
}
return $cached;
}
/**
* Get raw plugin data from WordPress registry, plus additional "basedir" field for resolving handle to actual file.
* @param string $handle Relative file path used as handle e.g. loco-translate/loco.php
*/
public static function get_plugin( string $handle ): ?array {
// plugin must be registered with WordPress
$search = self::get_plugins();
$data = $search[$handle] ?? null;
// else plugin is not known to WordPress
if( is_null($data) ){
$data = apply_filters( 'loco_missing_plugin', [], $handle );
}
// plugin not valid if name absent from raw data
if( empty($data['Name']) ){
return null;
}
// basedir is added by our get_plugins function, but filtered arrays could be broken
if( ! array_key_exists('basedir',$data) ){
Loco_error_AdminNotices::debug( sprintf('"basedir" property required to resolve %s',$handle) );
return null;
}
return $data;
}
/**
* {@inheritdoc}
*/
public function getHeaderInfo(): Loco_package_Header {
$handle = $this->getHandle();
$data = self::get_plugin($handle);
if( ! is_array($data) ){
// permitting direct file access if file exists (tests)
$path = $this->getBootstrapPath();
if( $path && file_exists($path) ){
$data = get_plugin_data( $path, false, false );
}
else {
$data = [];
}
}
return new Loco_package_Header( $data );
}
/**
* {@inheritdoc}
*/
public function getMetaTranslatable(): array {
return [
'Name' => 'Name of the plugin',
'Description' => 'Description of the plugin',
'PluginURI' => 'URI of the plugin',
'Author' => 'Author of the plugin',
'AuthorURI' => 'Author URI of the plugin',
// 'Tags' => 'Tags of the plugin',
];
}
/**
* {@inheritdoc}
*/
public function setHandle( string $handle ): Loco_package_Bundle {
// plugin handles are relative paths from plugin directory to bootstrap file
// so plugin is single file if its handle has no directory prefix
if( basename($handle) === $handle ){
$this->solo = true;
}
else {
$this->solo = false;
}
return parent::setHandle($handle);
}
/**
* {@inheritdoc}
*/
public function setDirectoryPath( string $path ): Loco_package_Bundle {
parent::setDirectoryPath($path);
// plugin bootstrap file can be inferred from base directory + handle
// e.g. if base is "/path/to/foo" and handle is "foo/bar.php" we can derive "/path/to/foo/bar.php"
if( ! $this->getBootstrapPath() ){
$handle = $this->getHandle();
if( '' !== $handle ) {
$file = new Loco_fs_File( basename($handle) );
$file->normalize( $path );
$this->setBootstrapPath( $file->getPath() );
}
}
return $this;
}
/**
* Create plugin bundle definition from WordPress plugin data.
*
* @param string $handle plugin handle relative to plugin directory
* @return Loco_package_Plugin
*/
public static function create( string $handle ): Loco_package_Plugin {
// plugin must be registered with at least a name and "basedir"
$data = self::get_plugin($handle);
if( ! $data ){
// translators: %s refers to the handle of a plugin, e.g. "loco-translate/loco.php"
throw new Loco_error_Exception( sprintf( __('Plugin not found: %s','loco-translate'),$handle) );
}
// lazy resolve of base directory from "basedir" property that we added
$file = new Loco_fs_File( $handle );
$file->normalize( $data['basedir'] );
$base = $file->dirname();
// handle and name is enough data to construct empty bundle
$bundle = new Loco_package_Plugin( $handle, $data['Name'] );
// check if listener heard the real text domain, but only use when none declared
// This will no longer happen since WP 4.6 header fallback, but we could warn about it
$listener = Loco_package_Listener::singleton();
if( $domain = $listener->getDomain($handle) ){
if( empty($data['TextDomain']) ){
$data['TextDomain'] = $domain;
if( empty($data['DomainPath']) ){
$data['DomainPath'] = $listener->getDomainPath($domain);
}
}
// ideally would only warn on certain pages, but unsure where to place this logic other than here
// TODO possibly allow bundle to hold errors/warnings as part of its config.
else if( $data['TextDomain'] !== $domain ){
Loco_error_AdminNotices::debug( sprintf("Plugin loaded text domain '%s' but WordPress knows it as '%s'",$domain, $data['TextDomain']) );
}
}
// do initial configuration of bundle from metadata
$bundle->configure( $base, $data );
return $bundle;
}
/**
* {@inheritDoc}
*/
public static function fromFile( Loco_fs_File $file ): ?Loco_package_Bundle {
$find = $file->getPath();
foreach( self::get_plugins() as $handle => $data ){
$boot = new Loco_fs_File( $handle );
$boot->normalize( $data['basedir'] );
// single file plugins can only match if given file is the plugin file itself.
if( basename($handle) === $handle ){
if( $boot->getPath() === $find ){
return self::create($handle);
}
}
// else check file is under plugin root.
else {
$base = $boot->dirname();
$path = $base.substr( $find, strlen($base) );
if( $path === $find ){
return self::create($handle);
}
}
}
return null;
}
}

View File

@@ -0,0 +1,800 @@
<?php
/**
* A project is a set of translations within a Text Domain.
* Often a text domain will have just one set, but this allows domains to be split into multiple POT files.
*/
class Loco_package_Project {
/**
* Text Domain in which project lives
*/
private Loco_package_TextDomain $domain;
/**
* Bundle in which project lives
*/
private Loco_package_Bundle $bundle;
/**
* Friendly project name, e.g. "Network Admin"
*/
private string $name;
/**
* Short name used for naming files, e.g "admin"
*/
private string $slug;
/**
* Configured domain path[s] not including global search paths
*/
private Loco_fs_FileList $dpaths;
/**
* Additional system domain path[s] added separately from bundle config
*/
private Loco_fs_FileList $gpaths;
/**
* Directory paths to exclude during target scanning
*/
private Loco_fs_FileList $xdpaths;
/**
* Locations where POT, PO and MO files may be saved, including standard global paths
*/
private ?Loco_fs_FileFinder $target = null;
/**
* Configured source path[s] not including global search paths
*/
private Loco_fs_FileList $spaths;
/**
* File and directory paths to exclude from source file extraction
*/
private Loco_fs_FileList $xspaths;
/**
* Locations where extractable source files may be found
*/
private ?Loco_fs_FileFinder $source = null;
/**
* Explicitly added individual PHP source files
*/
private Loco_fs_FileList $sfiles;
/**
* Paths globally excluded by bundle-level configuration
*/
private Loco_fs_FileList $xgpaths;
/**
* POT template file, ideally named "<name>.pot"
*/
private ?Loco_fs_File $pot = null;
/**
* Whether POT file is protected from end-user update and sync operations.
*/
private bool $potlock = false;
/**
* Construct project from its domain and a descriptive name
*/
public function __construct( Loco_package_Bundle $bundle, Loco_package_TextDomain $domain, string $name ){
$this->setName($name);
$this->bundle = $bundle;
$this->domain = $domain;
// take default slug from domain, avoiding wildcard
$slug = $domain->getName();
if( '*' === $slug ){
$slug = '';
}
$this->slug = $slug;
// sources
$this->sfiles = new Loco_fs_FileList;
$this->spaths = new Loco_fs_FileList;
$this->xspaths = new Loco_fs_FileList;
// targets
$this->dpaths = new Loco_fs_FileList;
$this->gpaths = new Loco_fs_FileList;
$this->xdpaths = new Loco_fs_FileList;
// global
$this->xgpaths = new Loco_fs_FileList;
}
/**
* Split project ID into domain and slug.
* null and "" are meaningfully different. "" means deliberately empty slug, whereas null means default
* @param string $id <domain>[.<slug>]
* @return string[] [ <domain>, <slug> ]
*/
public static function splitId( string $id ):array {
$r = preg_split('/(?<!\\\\)\\./', $id, 2 );
$domain = stripcslashes($r[0]);
$slug = isset($r[1]) ? stripcslashes($r[1]) : $domain;
return [ $domain, $slug ];
}
/**
* Get ID identifying project uniquely within a bundle
*/
public function getId():string {
$slug = $this->getSlug();
$domain = (string) $this->getDomain();
if( $slug === $domain ){
return $slug;
}
return addcslashes($domain,'.').'.'.addcslashes($slug,'.');
}
/**
* @return string
*/
public function __toString(){
return $this->name;
}
/**
* Set friendly name of project
*/
public function setName( string $name ):self {
$this->name = $name;
return $this;
}
/**
* Set short name of project
*/
public function setSlug( string $slug ):self {
$this->slug = $slug;
return $this;
}
/**
* Get friendly name of project, e.g. "Network Admin"
*/
public function getName():string {
return $this->name;
}
/**
* Get short name of project, e.g. "admin"
*/
public function getSlug():string {
return $this->slug;
}
/**
* Get text domain as stringable object
*/
public function getDomain():Loco_package_TextDomain {
return $this->domain;
}
/**
* Get the parent bundle that contains this project
*/
public function getBundle():Loco_package_Bundle {
return $this->bundle;
}
/**
* Whether project is the default for its domain.
*/
public function isDomainDefault():bool {
$slug = $this->getSlug();
$name = $this->getDomain()->getName();
// default if slug matches text domain.
// else special case for Core "default" domain which has empty slug
return $slug === $name || ( 'default' === $name && '' === $slug ) || 1 === count($this->bundle);
}
/**
* Add a root path where translation files may live
* @param string|Loco_fs_File $location
*/
public function addTargetDirectory( $location ):self {
$this->target = null;
$this->dpaths->add( new Loco_fs_Directory($location) );
return $this;
}
/**
* Add a global search path where translation files may live
* @param string|Loco_fs_Directory $location
*/
public function addSystemTargetDirectory( $location ):self {
$this->target = null;
$this->gpaths->add( new Loco_fs_Directory($location) );
return $this;
}
/**
* Get domain paths configured in project
* @return Loco_fs_FileList<int,Loco_fs_Directory>
*/
public function getConfiguredTargets():Loco_fs_FileList {
return $this->dpaths;
}
/**
* Get system paths added to project after configuration
*/
public function getSystemTargets():Loco_fs_FileList {
return $this->gpaths;
}
/**
* Get all target directory roots including global search paths
*/
public function getDomainTargets():Loco_fs_FileList {
return $this->getTargetFinder()->getRootDirectories();
}
/**
* Lazy create all searchable domain paths including global directories
*/
private function getTargetFinder():Loco_fs_FileFinder {
if( ! $this->target ){
$target = new Loco_fs_FileFinder;
$target->setRecursive(false)->group('pot','po','mo');
foreach( $this->dpaths as $path ){
// TODO search need not be recursive if it was the configured DomainPath
// currently no way to know at this point, so recursing by default.
$target->addRoot( (string) $path, true );
}
foreach( $this->gpaths as $path ){
$target->addRoot( (string) $path, false );
}
$this->excludeTargets( $target );
$this->target = $target;
}
return $this->target;
}
/**
* Utility excludes current exclude paths from target finder
*/
private function excludeTargets( Loco_fs_FileFinder $finder ):void {
foreach( $this->xdpaths as $file ){
if( $path = realpath( (string) $file ) ){
$finder->exclude( $path );
}
}
foreach( $this->xgpaths as $file ){
if( $path = realpath( (string) $file ) ){
$finder->exclude( $path );
}
}
}
/**
* Check if target file or directory is excluded
*/
private function isTargetExcluded( Loco_fs_File $file ):bool{
return $this->xgpaths->has($file) || $this->xdpaths->has($file);
}
/**
* Add a path for excluding in a recursive target file search
* @param string|Loco_fs_File $path
*/
public function excludeTargetPath( $path ):self {
$this->target = null;
$this->xdpaths->add( new Loco_fs_File($path) );
return $this;
}
/**
* Get all paths excluded when searching for targets
*/
public function getConfiguredTargetsExcluded():Loco_fs_FileList {
return $this->xdpaths;
}
/**
* Get first valid domain path
*/
private function getSafeDomainPath():Loco_fs_Directory {
// use first configured domain path that exists
foreach( $this->getConfiguredTargets() as $d ){
if( $d->exists() ){
return $d;
}
}
// fallback to unconfigured, but possibly existent folders
$base = $this->getBundle()->getDirectoryPath();
foreach( ['languages','language','lang','l10n','i18n'] as $d ){
$d = new Loco_fs_Directory($d);
$d->normalize($base);
if( $this->isTargetExcluded($d) ){
continue;
}
if( $d->exists() ){
return $d;
}
}
// Give up and place in root
return new Loco_fs_Directory($base);
}
/**
* Lazy create all searchable source paths
*/
public function getSourceFinder():Loco_fs_FileFinder {
if( ! $this->source ){
$source = new Loco_fs_FileFinder;
$exts = $this->getSourceExtensions();
$source->setRecursive(true)->filterExtensions($exts);
/* @var $file Loco_fs_File */
foreach( $this->spaths as $file ){
$path = realpath( (string) $file );
if( $path && is_dir($path) ){
$source->addRoot( $path, true );
}
}
$this->excludeSources( $source );
$this->source = $source;
}
return $this->source;
}
/**
* Get file extension filter for source code files
*/
public function getSourceExtensions():array {
// TODO source extensions should be moved from plugin settings to project settings
$conf = Loco_data_Settings::get();
$exts = $conf->php_alias;
$exts = array_merge( $exts, $conf->jsx_alias );
// ensure we always scan *.php and block.json files
return array_merge( $exts, ['php','json'] );
}
/**
* Utility excludes current exclude paths from passed target finder
*/
private function excludeSources( Loco_fs_FileFinder $finder ):void {
foreach( [$this->xspaths,$this->xgpaths] as $list ){
foreach( $list as $file ){
$real = realpath( (string) $file );
if( is_string($real) && '' !== $real ){
$finder->exclude($real);
}
}
}
}
/**
* Add a root path where source files may live under for this project
* @param string|Loco_fs_File $location
*/
public function addSourceDirectory( $location ):self {
$this->source = null;
$this->spaths->add( new Loco_fs_File($location) );
return $this;
}
/**
* Add Explicit source file to project config
* @param string|Loco_fs_File $path
*/
public function addSourceFile( $path ):self {
$this->source = null;
$this->sfiles->add( new Loco_fs_File($path) );
return $this;
}
/**
* Add a file or directory as a source location
* @param string|Loco_fs_File $path
*/
public function addSourceLocation( $path ):self {
$file = new Loco_fs_File( $path );
if( $file->isDirectory() ){
$this->addSourceDirectory( $file );
}
else {
$this->addSourceFile( $file );
}
return $this;
}
/**
* Get all source directories and files defined in project
*/
public function getConfiguredSources():Loco_fs_FileList {
$dynamic = $this->spaths->getArrayCopy();
$statics = $this->sfiles->getArrayCopy();
return new Loco_fs_FileList( array_merge( $dynamic, $statics ) );
}
/**
* Test if bundle has configured source files (even if they're excluded by other rules)
*/
public function hasSourceFiles():bool {
return count( $this->sfiles ) || count( $this->spaths );
}
/**
* Add a path for excluding in source file search
* @param string|Loco_fs_File $path
*/
public function excludeSourcePath( $path ):self {
$this->source = null;
$this->xspaths->add( new Loco_fs_File($path) );
return $this;
}
/**
* Get all paths excluded when searching for sources
*/
public function getConfiguredSourcesExcluded():Loco_fs_FileList {
return $this->xspaths;
}
/**
* Add a globally excluded location affecting sources and targets
* @param string|Loco_fs_File $path
*/
public function excludeLocation( $path ):self {
$this->source = null;
$this->target = null;
$this->xgpaths->add( new Loco_fs_File($path) );
return $this;
}
/**
* Check whether POT file is protected from end-user update and sync operations.
*/
public function isPotLocked():bool {
return $this->potlock;
}
/**
* Lock POT file to prevent end-user updates0
*/
public function setPotLock( bool $locked ):self {
$this->potlock = $locked;
return $this;
}
/**
* Get full path to template POT (file) whether it exists or nor
*/
public function getPot():Loco_fs_File {
if( ! $this->pot ){
$slug = $this->getSlug();
$name = ( $slug ?: $this->getDomain()->getName() ).'.pot';
if( '.pot' !== $name ){
// find actual file under configured domain paths
$targets = $this->getConfiguredTargets()->copy();
// always permit POT file in the bundle root (i.e. outside domain path)
if( $this->isDomainDefault() && $this->bundle->hasDirectoryPath() ){
$root = $this->bundle->getDirectoryPath();
$targets->add( new Loco_fs_Directory($root) );
// look in alternative language directories if only root is configured
if( 1 === count($targets) ){
foreach( ['languages','language','lang','l10n','i18n'] as $d ) {
$alt = new Loco_fs_Directory($root.'/'.$d);
if( ! $this->isTargetExcluded($alt) ){
$targets->add($alt);
}
}
}
}
// pot check is for exact name and not recursive
foreach( $targets as $dir ){
$file = new Loco_fs_File($name);
$file->normalize( $dir->getPath() );
if( $file->exists() && ! $this->isTargetExcluded($file) ){
$this->pot = $file;
break;
}
}
}
// fall back to a directory that exists, but where the POT may not
if( ! $this->pot ){
$this->pot = new Loco_fs_File($name);
$this->pot->normalize( (string) $this->getSafeDomainPath() );
}
}
return $this->pot;
}
/**
* Force the use of a known POT file. This could be a PO file if necessary
*/
public function setPot( Loco_fs_File $pot ):self {
$this->pot = $pot;
return $this;
}
/**
* Take a guess at most likely POT file under target locations
*/
public function guessPot():?Loco_fs_File {
$slug = $this->getSlug();
if( '' === $slug ){
$slug = (string) $this->getDomain();
if( '' === $slug ){
$slug = 'default';
}
}
// search only inside bundle for template
$finder = new Loco_fs_FileFinder;
foreach( $this->dpaths as $path ){
$finder->addRoot( (string) $path, true );
}
$this->excludeTargets($finder);
$files = $finder->group('pot','po','mo')->exportGroups();
foreach( ['pot','po'] as $ext ){
/* @var $pot Loco_fs_File */
foreach( $files[$ext] as $pot ){
$name = $pot->filename();
// use exact match on project slug if found
if( $slug === $name ){
return $pot;
}
// support unconventional "{slug}-en_US.{ext}"
foreach( ['-en_US'=>6, '-en'=>3 ] as $tail => $len ){
if( $tail === substr($name,-$len) && $slug === substr($name,0,-$len) ){
return $pot;
}
}
}
}
// Failed to find correctly named POT file,
// but if a single POT file is found we'll use it.
if( 1 === count($files['pot']) ){
return $files['pot'][0];
}
// Either no POT files are found, or multiple are found.
// if the project is the default in its domain, we can try aliases which may be PO
if( $this->isDomainDefault() ){
$options = Loco_data_Settings::get();
if( $aliases = $options->pot_alias ){
$found = [];
/* @var $pot Loco_fs_File */
foreach( $finder as $pot ){
$priority = array_search( $pot->basename(), $aliases, true );
if( false !== $priority ){
$found[$priority] = $pot;
}
}
if( $found ){
ksort( $found );
return current($found);
}
}
}
// failed to guess POT file
return null;
}
/**
* Get all extractable PHP source files found under all source paths
*/
public function findSourceFiles():Loco_fs_FileList {
$source = $this->getSourceFinder();
// augment file list from directories unless already done so
$list = $this->sfiles->copy();
$crawled = $source->exportGroups();
foreach( $crawled as $ext => $files ){
/* @var Loco_fs_File $file */
foreach( $files as $file ){
$name = $file->filename();
// skip "{name}.min.{ext}" but only if "{name}.{ext}" exists
if( '.min' === substr($name,-4) && file_exists( $file->dirname().'/'.substr($name,0,-4).'.'.$ext ) ){
continue;
}
// .json source files like block.json theme.json etc..
if( 'json' === $ext && 'block' !== $name && 'theme' !== $name ){
// arbitrarily named theme jsons, like onyx.json (twentytwentyfour)
if( ! $this->getBundle()->isTheme() ){
continue;
}
// Skip JED. We will merge these in separately as needed
if( preg_match('/-[0-9a-f]{32}]$/',$name ) ){
continue;
}
// Ok, treat as json schema file. May fail later...
}
$list->add($file);
}
}
return $list;
}
/**
* Get all translation files matching project prefix across target directories
* @param string $ext File extension, usually "po" or "mo"
*/
public function findLocaleFiles( string $ext ):Loco_fs_FileList {
$finder = $this->getTargetFinder();
$list = new Loco_fs_LocaleFileList;
$files = $finder->exportGroups();
$prefix = $this->getSlug();
$domain = $this->domain->getName();
$default = $this->isDomainDefault();
$prefs = Loco_data_Preferences::get();
/* @var $file Loco_fs_File */
foreach( $files[$ext] as $file ){
$file = new Loco_fs_LocaleFile( $file );
// restrict locale by user preference
if( $prefs && ! $prefs->has_locale( $file->getLocale() ) ){
continue;
}
// add file if prefix matches and has a suffix. locale will be validated later
if( $file->getPrefix() === $prefix && $file->getSuffix() ){
$list->addLocalized( $file );
}
// else in some cases a suffix-only file like "el.po" can match
else if( $default && $file->hasSuffixOnly() ){
// theme files under their own directory
if( $file->underThemeDirectory() ){
$list->addLocalized( $file );
}
// check followed links if they were originally under theme dir
else if( ( $link = $finder->getFollowed($file) ) && $link->underThemeDirectory() ){
$list->addLocalized( $file );
}
// WordPress core "default" domain, default project
else if( 'default' === $domain ){
$list->addLocalized( $file );
}
}
}
return $list;
}
/**
* Find files with extension that are not localized/translation files belonging to this project
*/
public function findNotLocaleFiles( string $ext ):Loco_fs_FileList {
$list = new Loco_fs_LocaleFileList;
$files = $this->getTargetFinder()->exportGroups();
/* @var $file Loco_fs_LocaleFile */
foreach( $files[$ext] as $file ){
$file = new Loco_fs_LocaleFile( $file );
// add file if it has no locale suffix and is inside the bundle
if( $file->hasPrefixOnly() && ! $file->underGlobalDirectory() ){
$list->add( $file );
}
}
return $list;
}
/**
* Initialize choice of PO file paths for a given locale
*/
public function initLocaleFiles( Loco_Locale $locale ):Loco_fs_FileList {
$slug = $this->getSlug();
$domain = $this->domain->getName();
$default = $this->isDomainDefault();
$suffix = sprintf( '%s.po', $locale );
$prefix = $slug ? sprintf('%s-',$slug) : '';
$choice = new Loco_fs_FileList;
/* @var Loco_fs_Directory $dir */
foreach( $this->getConfiguredTargets() as $dir ){
// theme files under their own directory normally have no file prefix
if( $default && $dir->underThemeDirectory() ){
$path = $dir->getPath().'/'.$suffix;
}
// all other paths use configured prefix, which may be empty
else {
$path = $dir->getPath().'/'.$prefix.$suffix;
}
$choice->add( new Loco_fs_LocaleFile($path) );
}
if( 'default' === $domain || '*' === $domain ){
$domain = '';
}
/* @var Loco_fs_Directory $dir */
foreach( $this->getSystemTargets() as $dir ){
$path = $dir->getPath();
// themes and plugins under global locations will be loaded by domain, regardless of prefix
if( ( '/themes' === substr($path,-7) || '/plugins' === substr($path,-8) ) && '' !== $domain ){
$path .= '/'.$domain.'-'.$suffix;
}
// all other paths (probably core) use the configured prefix, which may be empty
else {
$path .= '/'.$prefix.$suffix;
}
$choice->add( new Loco_fs_LocaleFile($path) );
}
return $choice;
}
/**
* Initialize a PO file path from required location
* @throws Loco_error_Exception
*/
public function initLocaleFile( Loco_fs_Directory $dir, Loco_Locale $locale ):Loco_fs_LocaleFile {
$choice = $this->initLocaleFiles($locale);
$pattern = '!^'.preg_quote($dir->getPath(),'!').'/[^/.]+\\.po$!';
/* @var Loco_fs_LocaleFile $file */
foreach( $choice as $file ){
if( preg_match($pattern,$file->getPath()) ){
return $file;
}
}
throw new Loco_error_Exception('Unexpected file location: '.$dir );
}
/**
* Get newest timestamp of all translation files (includes template, but excludes source files)
*/
public function getLastUpdated():int {
$t = 0;
$file = $this->getPot();
if( $file->exists() ){
$t = $file->modified();
}
/* @var Loco_fs_File $file */
foreach( $this->findLocaleFiles('po') as $file ){
$t = max( $t, $file->modified() );
}
return $t;
}
/**
* @codeCoverageIgnore
*/
public function __debugInfo():array {
return [
'id' => $this->getId(),
'domain' => (string) $this->getDomain(),
];
}
}

View File

@@ -0,0 +1,71 @@
<?php
/**
* Object represents a Text Domain within a bundle.
*/
class Loco_package_TextDomain extends ArrayIterator {
/**
* Actual Gettext-like name of Text Domain, e.g. "twentyfifteen"
*/
private string $name;
/**
* Whether this is the officially declared domain for a theme or plugin
*/
private bool $canonical = false;
/**
* Create new Text Domain from its name
*/
public function __construct( $name ){
parent::__construct();
$this->name = (string) $name;
}
/**
* @internal
*/
public function __toString(){
return $this->name;
}
/**
* Get name of Text Domain, e.g. "twentyfifteen"
*/
public function getName():string {
return $this->name;
}
/**
* Create a named project in a given bundle for this Text Domain
* @param Loco_package_Bundle $bundle of which this is one set of translations
*/
public function createProject( Loco_package_Bundle $bundle, string $name ): Loco_package_Project {
$proj = new Loco_package_Project( $bundle, $this, $name );
$this[] = $proj;
return $proj;
}
/**
* Set whether this is the officially declared domain
*/
public function setCanonical( bool $bool ): self {
$this->canonical = $bool;
return $this;
}
/**
* Check whether this is the officially declared domain
*/
public function isCanonical():bool {
return $this->canonical;
}
}

View File

@@ -0,0 +1,168 @@
<?php
/**
* Represents a bundle of type "theme"
*/
class Loco_package_Theme extends Loco_package_Bundle {
/**
* @var Loco_package_Theme|null
*/
private $parent;
/**
* {@inheritdoc}
*/
public function getSystemTargets():array {
return [
trailingslashit( loco_constant('LOCO_LANG_DIR') ).'themes',
trailingslashit( loco_constant('WP_LANG_DIR') ).'themes',
];
}
/**
* {@inheritdoc}
*/
public function isTheme():bool {
return true;
}
/**
* {@inheritdoc}
*/
public function getType():string {
return 'Theme';
}
/**
* {@inheritDoc}
*/
public function getDirectoryUrl(): string {
$slug = $this->getHandle();
return trailingslashit(get_theme_root_uri($slug)).$slug.'/';
}
/**
* {@inheritdoc}
*/
public function getHeaderInfo(): Loco_package_Header {
$root = dirname( $this->getDirectoryPath() );
$theme = new WP_Theme( $this->getSlug(), $root );
return new Loco_package_Header( $theme );
}
/**
* {@inheritdoc}
*/
public function getMetaTranslatable(): array {
return [
'Name' => 'Name of the theme',
'Description' => 'Description of the theme',
'ThemeURI' => 'URI of the theme',
'Author' => 'Author of the theme',
'AuthorURI' => 'Author URI of the theme',
// 'Tags' => 'Tags of the theme',
];
}
/**
* @inheritDoc
*/
public function getParent(): ?Loco_package_Theme {
return $this->parent;
}
/**
* @return static[]
*/
public static function getAll(): array {
$themes = [];
foreach( wp_get_themes(['errors'=>null]) as $theme ){
try {
$themes[] = self::createFromTheme($theme);
}
catch( Exception $e ){
// @codeCoverageIgnore
}
}
return $themes;
}
/**
* Create theme bundle definition from WordPress theme handle
*
* @param string $slug Short name of theme, e.g. "twentyfifteen"
* @param string $root Theme root if known
* @return self
*/
public static function create( string $slug, string $root = '' ):self {
return self::createFromTheme( wp_get_theme( $slug, $root ) );
}
/**
* Create theme bundle definition from WordPress theme data
*/
public static function createFromTheme( WP_Theme $theme ):self {
$slug = $theme->get_stylesheet();
$base = $theme->get_stylesheet_directory();
$name = $theme->get('Name') or $name = $slug;
if( ! $theme->exists() ){
throw new Loco_error_Exception('Theme not found: '.$name );
}
$bundle = new Loco_package_Theme( $slug, $name );
// ideally theme has declared its TextDomain
// if not, we can see if the Domain listener has picked it up
$domain = $theme->get('TextDomain') ?: Loco_package_Listener::singleton()->getDomain($slug);
// otherwise we won't try to guess as it results in silent problems when guess is wrong
// ideally theme has declared its DomainPath. if not, we can see if the listener has picked it up
// otherwise project will use theme root by default
$target = $theme->get('DomainPath') ?: Loco_package_Listener::singleton()->getDomainPath($domain);
$bundle->configure( $base, [
'Name' => $name,
'TextDomain' => $domain,
'DomainPath' => $target,
] );
// parent theme inheritance:
if( $parent = $theme->parent() ){
try {
$bundle->parent = self::createFromTheme($parent);
$bundle->inherit( $bundle->parent );
}
catch( Loco_error_Exception $e ){
Loco_error_AdminNotices::add($e);
}
}
return $bundle;
}
/**
* {@inheritDoc}
*/
public static function fromFile( Loco_fs_File $file ):?Loco_package_Bundle {
$find = $file->getPath();
foreach( wp_get_themes( ['errors'=>null] ) as $theme ){
$base = $theme->get_stylesheet_directory();
$path = $base.substr( $find, strlen($base) );
if( $find === $path ){
return self::createFromTheme($theme);
}
}
return null;
}
}