, directives: array}> Groups in appearance order. */ public static function parse( $text ) { $lines = preg_split( '/\R/', (string) $text ); $lines = is_array( $lines ) ? $lines : array(); $groups = array(); $agents = array(); $directives = array(); foreach ( $lines as $line ) { $line = self::strip_comment( (string) $line ); if ( '' === $line ) { continue; } $parts = explode( ':', $line, 2 ); if ( 2 !== count( $parts ) ) { continue; } $field = strtolower( trim( $parts[0] ) ); $value = trim( $parts[1] ); if ( 'user-agent' === $field ) { /* * A User-agent line after directives opens a new group; one that * follows another User-agent line adds to the current group's * agents. That is the whole of the format's grouping rule. */ if ( array() !== $directives ) { $groups[] = array( 'agents' => $agents, 'directives' => $directives, ); $agents = array(); $directives = array(); } if ( '' !== $value ) { $agents[] = strtolower( $value ); } continue; } if ( 'allow' !== $field && 'disallow' !== $field ) { continue; } // A directive before any User-agent line belongs to no group. if ( array() === $agents ) { continue; } $directives[] = array( 'type' => $field, 'path' => $value, ); } if ( array() !== $agents ) { $groups[] = array( 'agents' => $agents, 'directives' => $directives, ); } return $groups; } /** * Returns the agent tokens a response names, lower-cased. * * The wildcard is excluded: callers use this to decide whether a response * already addresses a bot by name, and `*` addresses no bot by name. * * @since 1.2.0 * * @param string $text Robots.txt response text. * @return array Lower-cased agent tokens as keys. */ public static function agents( $text ) { $agents = array(); foreach ( self::parse( $text ) as $group ) { foreach ( $group['agents'] as $agent ) { if ( self::WILDCARD_AGENT === $agent ) { continue; } $agents[ $agent ] = true; } } return $agents; } /** * Reports what the parsed groups say about one token's access to `/`. * * @since 1.2.0 * * @param array, directives: array}> $groups Parsed groups. * @param string $token Registry token to evaluate. * @return array{status: string, basis: string, scope: string} Status, its basis and the group scope it came from. */ public static function root_status( array $groups, $token ) { $directives = self::directives_for( $groups, strtolower( (string) $token ) ); $scope = self::SCOPE_EXACT; if ( null === $directives ) { $directives = self::directives_for( $groups, self::WILDCARD_AGENT ); $scope = self::SCOPE_WILDCARD; } if ( null === $directives ) { return array( 'status' => self::STATUS_NOT_MENTIONED, 'basis' => self::BASIS_NOT_MENTIONED, 'scope' => self::SCOPE_NONE, ); } $evaluated = self::evaluate_root( $directives ); $evaluated['scope'] = $scope; return $evaluated; } /** * Returns every directive of every group naming an agent, or null. * * Null and an empty array mean different things: null is "no group names * this agent", which falls through to the wildcard, while an empty array is * "a group names it and carries no directive", which does not. * * @since 1.2.0 * * @param array, directives: array}> $groups Parsed groups. * @param string $agent Lower-cased agent token. * @return array|null Merged directives in appearance order, or null. */ private static function directives_for( array $groups, $agent ) { $found = false; $directives = array(); foreach ( $groups as $group ) { if ( ! in_array( $agent, $group['agents'], true ) ) { continue; } $found = true; $directives = array_merge( $directives, $group['directives'] ); } return $found ? $directives : null; } /** * Decides access to `/` from a group's directives. * * Only `/` itself is an applicable path at the root: every other literal * path is longer and more specific, so it cannot match `/`. Where an * applicable Allow and Disallow are both present they are necessarily the * same length, and the format's tie-break awards equal-length matches to * Allow. * * @since 1.2.0 * * @param array $directives Directives in appearance order. * @return array{status: string, basis: string} Status and its basis. */ private static function evaluate_root( array $directives ) { $allow_root = false; $disallow_root = false; $unsupported = false; foreach ( $directives as $directive ) { $path = (string) $directive['path']; if ( false !== strpos( $path, '*' ) || false !== strpos( $path, '$' ) ) { $unsupported = true; continue; } /* * An empty value - "Disallow:" on its own - imposes no restriction, * and is handled by this same test rather than as a special case. */ if ( '/' !== $path ) { continue; } if ( 'allow' === $directive['type'] ) { $allow_root = true; continue; } $disallow_root = true; } if ( $allow_root && $disallow_root ) { return array( 'status' => self::STATUS_ALLOWED, 'basis' => self::BASIS_ALLOW_TIE_BREAK, ); } if ( $disallow_root ) { return array( 'status' => self::STATUS_BLOCKED, 'basis' => self::BASIS_DISALLOW_ROOT, ); } if ( $allow_root ) { return array( 'status' => self::STATUS_ALLOWED, 'basis' => self::BASIS_ALLOW_ROOT, ); } if ( $unsupported ) { return array( 'status' => self::STATUS_UNKNOWN, 'basis' => self::BASIS_WILDCARD_UNSUPPORTED, ); } return array( 'status' => self::STATUS_ALLOWED, 'basis' => self::BASIS_ALLOWED_AT_ROOT, ); } /** * Removes a comment and surrounding whitespace from one line. * * `#` starts a comment anywhere on a line in robots.txt, so this also * handles a whole-line comment without a separate test for it. * * @since 1.2.0 * * @param string $line Raw line. * @return string Line without its comment, trimmed. */ private static function strip_comment( $line ) { $hash = strpos( $line, '#' ); if ( false !== $hash ) { $line = substr( $line, 0, $hash ); } return trim( $line ); } }